From c578541a82f5ca5420c3d3fb69eace4d9d57b28d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 20 Jul 2021 15:57:11 +0200 Subject: [PATCH 001/112] Add new electrical unit constants (mV + mA) (#53158) --- homeassistant/components/bloomsky/sensor.py | 5 +++-- homeassistant/components/homematic/sensor.py | 3 ++- homeassistant/components/isy994/const.py | 6 ++++-- homeassistant/components/mysensors/sensor.py | 3 ++- homeassistant/components/omnilogic/sensor.py | 3 ++- homeassistant/components/ondilo_ico/sensor.py | 8 +++++++- homeassistant/components/poolsense/sensor.py | 7 ++++--- homeassistant/components/wled/const.py | 3 --- homeassistant/components/wled/sensor.py | 5 +++-- homeassistant/const.py | 2 ++ tests/components/wled/test_sensor.py | 12 +++++------- 11 files changed, 34 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 29ca198c1fc..7aa2fe9baba 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -6,6 +6,7 @@ from homeassistant.const import ( AREA_SQUARE_METERS, CONF_MONITORED_CONDITIONS, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_MILLIVOLT, PERCENTAGE, PRESSURE_INHG, PRESSURE_MBAR, @@ -32,7 +33,7 @@ SENSOR_UNITS_IMPERIAL = { "Humidity": PERCENTAGE, "Pressure": PRESSURE_INHG, "Luminance": f"cd/{AREA_SQUARE_METERS}", - "Voltage": "mV", + "Voltage": ELECTRIC_POTENTIAL_MILLIVOLT, } # Metric units @@ -41,7 +42,7 @@ SENSOR_UNITS_METRIC = { "Humidity": PERCENTAGE, "Pressure": PRESSURE_MBAR, "Luminance": f"cd/{AREA_SQUARE_METERS}", - "Voltage": "mV", + "Voltage": ELECTRIC_POTENTIAL_MILLIVOLT, } # Device class diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 62f2f0ccdff..2bc51f67896 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_MILLIAMPERE, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_MILLIMETERS, @@ -47,7 +48,7 @@ HM_UNIT_HA_CAST = { "ACTUAL_TEMPERATURE": TEMP_CELSIUS, "BRIGHTNESS": "#", "POWER": POWER_WATT, - "CURRENT": "mA", + "CURRENT": ELECTRIC_CURRENT_MILLIAMPERE, "VOLTAGE": VOLT, "ENERGY_COUNTER": ENERGY_WATT_HOUR, "GAS_POWER": VOLUME_CUBIC_METERS, diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 34b89baad68..03b1fa6c66b 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -48,6 +48,8 @@ from homeassistant.const import ( CURRENCY_CENT, CURRENCY_DOLLAR, DEGREE, + ELECTRIC_CURRENT_MILLIAMPERE, + ELECTRIC_POTENTIAL_MILLIVOLT, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, @@ -371,9 +373,9 @@ UOM_FRIENDLY_NAME = { "38": LENGTH_METERS, "39": VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, "40": SPEED_METERS_PER_SECOND, - "41": "mA", + "41": ELECTRIC_CURRENT_MILLIAMPERE, "42": TIME_MILLISECONDS, - "43": "mV", + "43": ELECTRIC_POTENTIAL_MILLIVOLT, "44": TIME_MINUTES, "45": TIME_MINUTES, "46": PRECIPITATION_MILLIMETERS_PER_HOUR, diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 467ab761124..f16f9e09c41 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( DEGREE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_MILLIVOLT, ELECTRICAL_CURRENT_AMPERE, ELECTRICAL_VOLT_AMPERE, ENERGY_KILO_WATT_HOUR, @@ -61,7 +62,7 @@ SENSORS: dict[str, list[str | None] | dict[str, list[str | None]]] = { "V_VOLTAGE": [VOLT, "mdi:flash", None], "V_CURRENT": [ELECTRICAL_CURRENT_AMPERE, "mdi:flash-auto", None], "V_PH": ["pH", None, None], - "V_ORP": ["mV", None, None], + "V_ORP": [ELECTRIC_POTENTIAL_MILLIVOLT, None, None], "V_EC": [CONDUCTIVITY, None, None], "V_VAR": ["var", None, None], "V_VA": [ELECTRICAL_VOLT_AMPERE, None, None], diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index beec071b192..1f8de082868 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -2,6 +2,7 @@ from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE, SensorEntity from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, + ELECTRIC_POTENTIAL_MILLIVOLT, MASS_GRAMS, PERCENTAGE, TEMP_CELSIUS, @@ -342,7 +343,7 @@ SENSOR_TYPES = { "kind": "csad_orp", "device_class": None, "icon": "mdi:gauge", - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "guard_condition": [ {"orp": ""}, ], diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 2428862cb31..633e03157e4 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_MILLIVOLT, PERCENTAGE, TEMP_CELSIUS, ) @@ -28,7 +29,12 @@ SENSOR_TYPES = { None, DEVICE_CLASS_TEMPERATURE, ], - "orp": ["Oxydo Reduction Potential", "mV", "mdi:pool", None], + "orp": [ + "Oxydo Reduction Potential", + ELECTRIC_POTENTIAL_MILLIVOLT, + "mdi:pool", + None, + ], "ph": ["pH", "", "mdi:pool", None], "tds": ["TDS", CONCENTRATION_PARTS_PER_MILLION, "mdi:pool", None], "battery": ["Battery", PERCENTAGE, None, DEVICE_CLASS_BATTERY], diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index ca79fde6b08..dd03111e85e 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -6,6 +6,7 @@ from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + ELECTRIC_POTENTIAL_MILLIVOLT, PERCENTAGE, TEMP_CELSIUS, ) @@ -15,7 +16,7 @@ from .const import ATTRIBUTION, DOMAIN SENSORS = { "Chlorine": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine", "device_class": None, @@ -40,13 +41,13 @@ SENSORS = { "device_class": DEVICE_CLASS_TIMESTAMP, }, "Chlorine High": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine High", "device_class": None, }, "Chlorine Low": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine Low", "device_class": None, diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index d80dbf16a60..765de468350 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -30,9 +30,6 @@ ATTR_SPEED = "speed" ATTR_TARGET_BRIGHTNESS = "target_brightness" ATTR_UDP_PORT = "udp_port" -# Units of measurement -CURRENT_MA = "mA" - # Services SERVICE_EFFECT = "effect" SERVICE_PRESET = "preset" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 37311e333c3..634f903c020 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( DATA_BYTES, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TIMESTAMP, + ELECTRIC_CURRENT_MILLIAMPERE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow -from .const import ATTR_LED_COUNT, ATTR_MAX_POWER, CURRENT_MA, DOMAIN +from .const import ATTR_LED_COUNT, ATTR_MAX_POWER, DOMAIN from .coordinator import WLEDDataUpdateCoordinator from .models import WLEDEntity @@ -47,7 +48,7 @@ class WLEDEstimatedCurrentSensor(WLEDEntity, SensorEntity): """Defines a WLED estimated current sensor.""" _attr_icon = "mdi:power" - _attr_unit_of_measurement = CURRENT_MA + _attr_unit_of_measurement = ELECTRIC_CURRENT_MILLIAMPERE _attr_device_class = DEVICE_CLASS_CURRENT def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: diff --git a/homeassistant/const.py b/homeassistant/const.py index 6e2b7e17ce0..fb47ef7cb7c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -407,8 +407,10 @@ ENERGY_WATT_HOUR: Final = "Wh" ENERGY_KILO_WATT_HOUR: Final = "kWh" # Electrical units +ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" ELECTRICAL_CURRENT_AMPERE: Final = "A" ELECTRICAL_VOLT_AMPERE: Final = "VA" +ELECTRIC_POTENTIAL_MILLIVOLT: Final = "mV" # Degree units DEGREE: Final = "°" diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index 4f2b07f4f51..e6e4b130d99 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -10,17 +10,13 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TIMESTAMP, DOMAIN as SENSOR_DOMAIN, ) -from homeassistant.components.wled.const import ( - ATTR_LED_COUNT, - ATTR_MAX_POWER, - CURRENT_MA, - DOMAIN, -) +from homeassistant.components.wled.const import ATTR_LED_COUNT, ATTR_MAX_POWER, DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, DATA_BYTES, + ELECTRIC_CURRENT_MILLIAMPERE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, STATE_UNKNOWN, @@ -101,7 +97,9 @@ async def test_sensors( assert state.attributes.get(ATTR_ICON) == "mdi:power" assert state.attributes.get(ATTR_LED_COUNT) == 30 assert state.attributes.get(ATTR_MAX_POWER) == 850 - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENT_MA + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_MILLIAMPERE + ) assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT assert state.state == "470" From f819be7acc9e80a46439238a48aa26b14699ecb6 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 20 Jul 2021 17:26:00 +0200 Subject: [PATCH 002/112] Correct typing in Insteon and activate mypy (#53222) --- homeassistant/components/insteon/fan.py | 4 +++- homeassistant/components/insteon/ipdb.py | 2 +- homeassistant/components/insteon/schemas.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 00ada3e9a58..b76661d7dde 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -1,4 +1,6 @@ """Support for INSTEON fans via PowerLinc Modem.""" +from __future__ import annotations + import math from homeassistant.components.fan import ( @@ -39,7 +41,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity): """An INSTEON fan entity.""" @property - def percentage(self) -> int: + def percentage(self) -> int | None: """Return the current speed percentage.""" if self._insteon_device_group.value is None: return None diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index 48223981103..9b32bc40043 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -110,4 +110,4 @@ def get_device_platforms(device): def get_platform_groups(device, domain) -> dict: """Return the platforms that a device belongs in.""" - return DEVICE_PLATFORM.get(type(device), {}).get(domain, {}) + return DEVICE_PLATFORM.get(type(device), {}).get(domain, {}) # type: ignore diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index c43df24b4cb..5fb46735f29 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -161,7 +161,7 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) -def normalize_byte_entry_to_int(entry: [int, bytes, str]): +def normalize_byte_entry_to_int(entry: int | bytes | str): """Format a hex entry value.""" if isinstance(entry, int): if entry in range(0, 256): diff --git a/mypy.ini b/mypy.ini index 9f37f46d713..4bfb8e22fe3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1277,9 +1277,6 @@ ignore_errors = true [mypy-homeassistant.components.input_number.*] ignore_errors = true -[mypy-homeassistant.components.insteon.*] -ignore_errors = true - [mypy-homeassistant.components.ipp.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 666575a86ed..720c9157e14 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -84,7 +84,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.influxdb.*", "homeassistant.components.input_datetime.*", "homeassistant.components.input_number.*", - "homeassistant.components.insteon.*", "homeassistant.components.ipp.*", "homeassistant.components.isy994.*", "homeassistant.components.izone.*", From 1fe2d0f9c80da77000aa2ac2ea4010352cb52454 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 20 Jul 2021 08:41:48 -0700 Subject: [PATCH 003/112] Address style issues in nest typing (#53236) * Add additional types for config flow Fixing style errors introduced by partial typing in pr #53214 * Address typing style errors Make all functions fully typed, follow up to pr #53214 --- homeassistant/components/nest/config_flow.py | 33 ++++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 2070f232d77..eeeae4b1ddd 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -17,11 +17,12 @@ import asyncio from collections import OrderedDict import logging import os +from typing import Any import async_timeout import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow @@ -80,7 +81,7 @@ class NestFlowHandler( self._reauth = False @classmethod - def register_sdm_api(cls, hass) -> None: + def register_sdm_api(cls, hass: HomeAssistant) -> None: """Configure the flow handler to use the SDM API.""" if DOMAIN not in hass.data: hass.data[DOMAIN] = {} @@ -105,7 +106,7 @@ class NestFlowHandler( "prompt": "consent", } - async def async_oauth_create_entry(self, data: dict) -> FlowResult: + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the SDM flow.""" assert self.is_sdm_api(), "Step only supported for SDM API" data[DATA_SDM] = {} @@ -129,13 +130,17 @@ class NestFlowHandler( return await super().async_oauth_create_entry(data) - async def async_step_reauth(self, user_input=None) -> FlowResult: + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Perform reauth upon an API authentication error.""" assert self.is_sdm_api(), "Step only supported for SDM API" self._reauth = True # Forces update of existing config entry return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None) -> FlowResult: + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm reauth dialog.""" assert self.is_sdm_api(), "Step only supported for SDM API" if user_input is None: @@ -145,7 +150,9 @@ class NestFlowHandler( ) return await self.async_step_user() - 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 initialized by the user.""" if self.is_sdm_api(): # Reauth will update an existing entry @@ -154,7 +161,9 @@ class NestFlowHandler( return await super().async_step_user(user_input) return await self.async_step_init(user_input) - async def async_step_init(self, user_input=None) -> FlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow start.""" assert not self.is_sdm_api(), "Step only supported for legacy API" @@ -179,7 +188,9 @@ class NestFlowHandler( data_schema=vol.Schema({vol.Required("flow_impl"): vol.In(list(flows))}), ) - async def async_step_link(self, user_input=None) -> FlowResult: + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the Nest account. Route the user to a website to authenticate with Nest. Depending on @@ -226,7 +237,7 @@ class NestFlowHandler( errors=errors, ) - async def async_step_import(self, info) -> FlowResult: + async def async_step_import(self, info: dict[str, Any]) -> FlowResult: """Import existing auth from Nest.""" assert not self.is_sdm_api(), "Step only supported for legacy API" @@ -247,7 +258,9 @@ class NestFlowHandler( ) @callback - def _entry_from_tokens(self, title, flow, tokens) -> FlowResult: + def _entry_from_tokens( + self, title: str, flow: dict[str, Any], tokens: list[Any] | dict[Any, Any] + ) -> FlowResult: """Create an entry from tokens.""" return self.async_create_entry( title=title, data={"tokens": tokens, "impl_domain": flow["domain"]} From 2b9b346a2822dfa42bb14678e379479a15190e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 20 Jul 2021 17:52:22 +0200 Subject: [PATCH 004/112] Address late review of Co2 signal (#53232) --- homeassistant/components/co2signal/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 044dd95cc3b..953a09719ec 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -114,9 +114,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: new_entry_type = TYPE_USE_HOME - for entry in self._async_current_entries(include_ignore=True): - if entry.source == config_entries.SOURCE_IGNORE: - continue + for entry in self._async_current_entries(include_ignore=False): if (cur_entry_type := _get_entry_type(entry.data)) != new_entry_type: continue From 0cc4231ac20924c3efa46f7be739688679d7237a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 20 Jul 2021 17:57:35 +0200 Subject: [PATCH 005/112] Tibber use dataclass (#53233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tibber, use dataclass Signed-off-by: Daniel Hjelseth Høyer * Tibber, use dataclass Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tibber/sensor.py | 174 ++++++++++++---------- 1 file changed, 94 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 8a296fd93b2..65f2f5e17a1 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -1,6 +1,10 @@ """Support for Tibber sensors.""" +from __future__ import annotations + import asyncio +from dataclasses import dataclass from datetime import timedelta +from enum import Enum import logging from random import randrange @@ -45,108 +49,142 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PARALLEL_UPDATES = 0 SIGNAL_UPDATE_ENTITY = "tibber_rt_update_{}" -RT_SENSOR_MAP = { - "averagePower": ["average power", DEVICE_CLASS_POWER, POWER_WATT, None], - "power": ["power", DEVICE_CLASS_POWER, POWER_WATT, None], - "powerProduction": ["power production", DEVICE_CLASS_POWER, POWER_WATT, None], - "minPower": ["min power", DEVICE_CLASS_POWER, POWER_WATT, None], - "maxPower": ["max power", DEVICE_CLASS_POWER, POWER_WATT, None], - "accumulatedConsumption": [ + +class ResetType(Enum): + """Data reset type.""" + + HOURLY = "hourly" + DAILY = "daily" + NEVER = "never" + + +@dataclass +class TibberSensorMetadata: + """Metadata for an individual Tibber sensor.""" + + name: str + device_class: str + unit: str | None = None + state_class: str | None = None + reset_type: ResetType | None = None + + +RT_SENSOR_MAP: dict[str, TibberSensorMetadata] = { + "averagePower": TibberSensorMetadata( + "average power", DEVICE_CLASS_POWER, POWER_WATT + ), + "power": TibberSensorMetadata( + "power", + DEVICE_CLASS_POWER, + POWER_WATT, + ), + "powerProduction": TibberSensorMetadata( + "power production", DEVICE_CLASS_POWER, POWER_WATT + ), + "minPower": TibberSensorMetadata("min power", DEVICE_CLASS_POWER, POWER_WATT), + "maxPower": TibberSensorMetadata("max power", DEVICE_CLASS_POWER, POWER_WATT), + "accumulatedConsumption": TibberSensorMetadata( "accumulated consumption", DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, STATE_CLASS_MEASUREMENT, - ], - "accumulatedConsumptionLastHour": [ + ResetType.DAILY, + ), + "accumulatedConsumptionLastHour": TibberSensorMetadata( "accumulated consumption current hour", DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, STATE_CLASS_MEASUREMENT, - ], - "accumulatedProduction": [ + ResetType.HOURLY, + ), + "accumulatedProduction": TibberSensorMetadata( "accumulated production", DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, STATE_CLASS_MEASUREMENT, - ], - "accumulatedProductionLastHour": [ + ResetType.DAILY, + ), + "accumulatedProductionLastHour": TibberSensorMetadata( "accumulated production current hour", DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, STATE_CLASS_MEASUREMENT, - ], - "lastMeterConsumption": [ + ResetType.HOURLY, + ), + "lastMeterConsumption": TibberSensorMetadata( "last meter consumption", DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, STATE_CLASS_MEASUREMENT, - ], - "lastMeterProduction": [ + ), + "lastMeterProduction": TibberSensorMetadata( "last meter production", DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, STATE_CLASS_MEASUREMENT, - ], - "voltagePhase1": [ + ), + "voltagePhase1": TibberSensorMetadata( "voltage phase1", DEVICE_CLASS_VOLTAGE, VOLT, STATE_CLASS_MEASUREMENT, - ], - "voltagePhase2": [ + ), + "voltagePhase2": TibberSensorMetadata( "voltage phase2", DEVICE_CLASS_VOLTAGE, VOLT, STATE_CLASS_MEASUREMENT, - ], - "voltagePhase3": [ + ), + "voltagePhase3": TibberSensorMetadata( "voltage phase3", DEVICE_CLASS_VOLTAGE, VOLT, STATE_CLASS_MEASUREMENT, - ], - "currentL1": [ + ), + "currentL1": TibberSensorMetadata( "current L1", DEVICE_CLASS_CURRENT, ELECTRICAL_CURRENT_AMPERE, STATE_CLASS_MEASUREMENT, - ], - "currentL2": [ + ), + "currentL2": TibberSensorMetadata( "current L2", DEVICE_CLASS_CURRENT, ELECTRICAL_CURRENT_AMPERE, STATE_CLASS_MEASUREMENT, - ], - "currentL3": [ + ), + "currentL3": TibberSensorMetadata( "current L3", DEVICE_CLASS_CURRENT, ELECTRICAL_CURRENT_AMPERE, STATE_CLASS_MEASUREMENT, - ], - "signalStrength": [ + ), + "signalStrength": TibberSensorMetadata( "signal strength", DEVICE_CLASS_SIGNAL_STRENGTH, SIGNAL_STRENGTH_DECIBELS, STATE_CLASS_MEASUREMENT, - ], - "accumulatedReward": [ + ), + "accumulatedReward": TibberSensorMetadata( "accumulated reward", DEVICE_CLASS_MONETARY, None, STATE_CLASS_MEASUREMENT, - ], - "accumulatedCost": [ + ResetType.DAILY, + ), + "accumulatedCost": TibberSensorMetadata( "accumulated cost", DEVICE_CLASS_MONETARY, None, STATE_CLASS_MEASUREMENT, - ], - "powerFactor": [ + ResetType.DAILY, + ), + "powerFactor": TibberSensorMetadata( "power factor", DEVICE_CLASS_POWER_FACTOR, PERCENTAGE, STATE_CLASS_MEASUREMENT, - ], + ), } @@ -312,39 +350,30 @@ class TibberSensorRT(TibberSensor): _attr_should_poll = False - def __init__( - self, tibber_home, sensor_name, device_class, unit, initial_state, state_class - ): + def __init__(self, tibber_home, metadata: TibberSensorMetadata, initial_state): """Initialize the sensor.""" super().__init__(tibber_home) - self._sensor_name = sensor_name self._model = "Tibber Pulse" self._device_name = f"{self._model} {self._home_name}" + self._metadata = metadata - self._attr_device_class = device_class - self._attr_name = f"{self._sensor_name} {self._home_name}" + self._attr_device_class = metadata.device_class + self._attr_name = f"{metadata.name} {self._home_name}" self._attr_state = initial_state - self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{self._sensor_name}" - self._attr_unit_of_measurement = unit - self._attr_state_class = state_class - if sensor_name in [ - "last meter consumption", - "last meter production", - ]: + self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{metadata.name}" + + if metadata.name in ["accumulated cost", "accumulated reward"]: + self._attr_unit_of_measurement = tibber_home.currency + else: + self._attr_unit_of_measurement = metadata.unit + self._attr_state_class = metadata.state_class + if metadata.reset_type == ResetType.NEVER: self._attr_last_reset = dt_util.utc_from_timestamp(0) - elif self._sensor_name in [ - "accumulated consumption", - "accumulated production", - "accumulated cost", - "accumulated reward", - ]: + elif metadata.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 self._sensor_name in [ - "accumulated consumption current hour", - "accumulated production current hour", - ]: + elif metadata.reset_type == ResetType.HOURLY: self._attr_last_reset = dt_util.as_utc( dt_util.now().replace(minute=0, second=0, microsecond=0) ) @@ -369,19 +398,11 @@ class TibberSensorRT(TibberSensor): @callback def _set_state(self, state, timestamp): """Set sensor state.""" - if state < self._attr_state and self._sensor_name in [ - "accumulated consumption", - "accumulated production", - "accumulated cost", - "accumulated reward", - ]: + if state < self._attr_state and self._metadata.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 and self._sensor_name in [ - "accumulated consumption current hour", - "accumulated production current hour", - ]: + if state < self._attr_state and self._metadata.reset_type == ResetType.HOURLY: self._attr_last_reset = dt_util.as_utc( timestamp.replace(minute=0, second=0, microsecond=0) ) @@ -427,18 +448,11 @@ class TibberRtDataHandler: timestamp, ) else: - sensor_name, device_class, unit, state_class = RT_SENSOR_MAP[ - sensor_type - ] - if sensor_type in ["accumulatedCost", "accumulatedReward"]: - unit = self._tibber_home.currency + sensor_meta = RT_SENSOR_MAP[sensor_type] entity = TibberSensorRT( self._tibber_home, - sensor_name, - device_class, - unit, + sensor_meta, state, - state_class, ) new_entities.append(entity) self._entities[sensor_type] = entity.unique_id From 9b705ad6dfddaa97ea135b93ce570a284c5ae731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 06:12:56 -1000 Subject: [PATCH 006/112] Update lock entity to support locking, unlocking, jammed (#51455) --- homeassistant/components/demo/lock.py | 76 +++++++-- homeassistant/components/lock/__init__.py | 27 ++++ .../components/lock/device_condition.py | 19 ++- .../components/lock/device_trigger.py | 13 +- .../components/lock/reproduce_state.py | 8 +- homeassistant/const.py | 3 + tests/components/demo/test_lock.py | 47 +++++- tests/components/google_assistant/__init__.py | 7 + .../components/lock/test_device_condition.py | 101 +++++++++++- tests/components/lock/test_device_trigger.py | 144 +++++++++++++++++- 10 files changed, 418 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index cafc0e3f748..7eabf9bea2d 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -1,6 +1,14 @@ """Demo lock platform that has two fake locks.""" +import asyncio + from homeassistant.components.lock import SUPPORT_OPEN, LockEntity -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -9,6 +17,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= [ DemoLock("Front Door", STATE_LOCKED), DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), DemoLock("Openable Lock", STATE_LOCKED, True), ] ) @@ -24,24 +33,67 @@ class DemoLock(LockEntity): _attr_should_poll = False - def __init__(self, name: str, state: str, openable: bool = False) -> None: + def __init__( + self, + name: str, + state: str, + openable: bool = False, + jam_on_operation: bool = False, + ) -> None: """Initialize the lock.""" self._attr_name = name - self._attr_is_locked = state == STATE_LOCKED if openable: self._attr_supported_features = SUPPORT_OPEN + self._state = state + self._openable = openable + self._jam_on_operation = jam_on_operation - def lock(self, **kwargs): + @property + def is_locking(self): + """Return true if lock is locking.""" + return self._state == STATE_LOCKING + + @property + def is_unlocking(self): + """Return true if lock is unlocking.""" + return self._state == STATE_UNLOCKING + + @property + def is_jammed(self): + """Return true if lock is jammed.""" + return self._state == STATE_JAMMED + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + async def async_lock(self, **kwargs): """Lock the device.""" - self._attr_is_locked = True - self.schedule_update_ha_state() + self._state = STATE_LOCKING + self.async_write_ha_state() + await asyncio.sleep(2) + if self._jam_on_operation: + self._state = STATE_JAMMED + else: + self._state = STATE_LOCKED + self.async_write_ha_state() - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" - self._attr_is_locked = False - self.schedule_update_ha_state() + self._state = STATE_UNLOCKING + self.async_write_ha_state() + await asyncio.sleep(2) + self._state = STATE_UNLOCKED + self.async_write_ha_state() - def open(self, **kwargs): + async def async_open(self, **kwargs): """Open the door latch.""" - self._attr_is_locked = False - self.schedule_update_ha_state() + self._state = STATE_UNLOCKED + self.async_write_ha_state() + + @property + def supported_features(self): + """Flag supported features.""" + if self._openable: + return SUPPORT_OPEN diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 9e8bf3a740c..e98202a1ee5 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -15,8 +15,11 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -87,6 +90,9 @@ class LockEntity(Entity): _attr_changed_by: str | None = None _attr_code_format: str | None = None _attr_is_locked: bool | None = None + _attr_is_locking: bool | None = None + _attr_is_unlocking: bool | None = None + _attr_is_jammed: bool | None = None _attr_state: None = None @property @@ -104,6 +110,21 @@ class LockEntity(Entity): """Return true if the lock is locked.""" return self._attr_is_locked + @property + def is_locking(self) -> bool | None: + """Return true if the lock is locking.""" + return self._attr_is_locking + + @property + def is_unlocking(self) -> bool | None: + """Return true if the lock is unlocking.""" + return self._attr_is_unlocking + + @property + def is_jammed(self) -> bool | None: + """Return true if the lock is jammed (incomplete locking).""" + return self._attr_is_jammed + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -143,6 +164,12 @@ class LockEntity(Entity): @property def state(self) -> str | None: """Return the state.""" + if self.is_jammed: + return STATE_JAMMED + if self.is_locking: + return STATE_LOCKING + if self.is_unlocking: + return STATE_UNLOCKING locked = self.is_locked if locked is None: return None diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index 3e77a23ffdb..d0829eb742b 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -10,8 +10,11 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry @@ -20,7 +23,13 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -CONDITION_TYPES = {"is_locked", "is_unlocked"} +CONDITION_TYPES = { + "is_locked", + "is_unlocked", + "is_locking", + "is_unlocking", + "is_jammed", +} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { @@ -60,7 +69,13 @@ def async_condition_from_config( """Create a function to test a device condition.""" if config_validation: config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_locked": + if config[CONF_TYPE] == "is_jammed": + state = STATE_JAMMED + elif config[CONF_TYPE] == "is_locking": + state = STATE_LOCKING + elif config[CONF_TYPE] == "is_unlocking": + state = STATE_UNLOCKING + elif config[CONF_TYPE] == "is_locked": state = STATE_LOCKED else: state = STATE_UNLOCKED diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 2e96b470893..641030e9f23 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -13,8 +13,11 @@ from homeassistant.const import ( CONF_FOR, CONF_PLATFORM, CONF_TYPE, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry @@ -22,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN -TRIGGER_TYPES = {"locked", "unlocked"} +TRIGGER_TYPES = {"locked", "unlocked", "locking", "unlocking", "jammed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { @@ -74,7 +77,13 @@ async def async_attach_trigger( automation_info: dict, ) -> CALLBACK_TYPE: """Attach a trigger.""" - if config[CONF_TYPE] == "locked": + if config[CONF_TYPE] == "jammed": + to_state = STATE_JAMMED + elif config[CONF_TYPE] == "locking": + to_state = STATE_LOCKING + elif config[CONF_TYPE] == "unlocking": + to_state = STATE_UNLOCKING + elif config[CONF_TYPE] == "locked": to_state = STATE_LOCKED else: to_state = STATE_UNLOCKED diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index ea5cf370af6..cdd538c88be 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -11,7 +11,9 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import Context, HomeAssistant, State @@ -19,7 +21,7 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) -VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED} +VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED, STATE_LOCKING, STATE_UNLOCKING} async def _async_reproduce_state( @@ -48,9 +50,9 @@ async def _async_reproduce_state( service_data = {ATTR_ENTITY_ID: state.entity_id} - if state.state == STATE_LOCKED: + if state.state in {STATE_LOCKED, STATE_LOCKING}: service = SERVICE_LOCK - elif state.state == STATE_UNLOCKED: + elif state.state in {STATE_UNLOCKED, STATE_UNLOCKING}: service = SERVICE_UNLOCK await hass.services.async_call( diff --git a/homeassistant/const.py b/homeassistant/const.py index fb47ef7cb7c..1d754b78b7c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -274,6 +274,9 @@ STATE_ALARM_DISARMING: Final = "disarming" STATE_ALARM_TRIGGERED: Final = "triggered" STATE_LOCKED: Final = "locked" STATE_UNLOCKED: Final = "unlocked" +STATE_LOCKING: Final = "locking" +STATE_UNLOCKING: Final = "unlocking" +STATE_JAMMED: Final = "jammed" STATE_UNAVAILABLE: Final = "unavailable" STATE_OK: Final = "ok" STATE_PROBLEM: Final = "problem" diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index bf8c0ddb63d..15e4e14524d 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -1,4 +1,6 @@ """The tests for the Demo lock platform.""" +import asyncio + import pytest from homeassistant.components.demo import DOMAIN @@ -7,8 +9,11 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.setup import async_setup_component @@ -17,6 +22,7 @@ from tests.common import async_mock_service FRONT = "lock.front_door" KITCHEN = "lock.kitchen_door" +POORLY_INSTALLED = "lock.poorly_installed_door" OPENABLE_LOCK = "lock.openable_lock" @@ -35,9 +41,13 @@ async def test_locking(hass): assert state.state == STATE_UNLOCKED await hass.services.async_call( - LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: KITCHEN}, blocking=True + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: KITCHEN}, blocking=False ) + await asyncio.sleep(1) + state = hass.states.get(KITCHEN) + assert state.state == STATE_LOCKING + await asyncio.sleep(2) state = hass.states.get(KITCHEN) assert state.state == STATE_LOCKED @@ -48,17 +58,46 @@ async def test_unlocking(hass): assert state.state == STATE_LOCKED await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: FRONT}, blocking=True + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: FRONT}, blocking=False ) - + await asyncio.sleep(1) + state = hass.states.get(FRONT) + assert state.state == STATE_UNLOCKING + await asyncio.sleep(2) state = hass.states.get(FRONT) assert state.state == STATE_UNLOCKED -async def test_opening(hass): +async def test_jammed_when_locking(hass): + """Test the locking of a lock jams.""" + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_UNLOCKED + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: POORLY_INSTALLED}, blocking=False + ) + + await asyncio.sleep(1) + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_LOCKING + await asyncio.sleep(2) + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_JAMMED + + +async def test_opening_mocked(hass): """Test the opening of a lock.""" calls = async_mock_service(hass, LOCK_DOMAIN, SERVICE_OPEN) await hass.services.async_call( LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True ) assert len(calls) == 1 + + +async def test_opening(hass): + """Test the opening of a lock.""" + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True + ) + state = hass.states.get(OPENABLE_LOCK) + assert state.state == STATE_UNLOCKED diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index a8b44511fb2..f7537db18de 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -382,6 +382,13 @@ DEMO_DEVICES = [ "type": "action.devices.types.LOCK", "willReportState": False, }, + { + "id": "lock.poorly_installed_door", + "name": {"name": "Poorly Installed Door"}, + "traits": ["action.devices.traits.LockUnlock"], + "type": "action.devices.types.LOCK", + "willReportState": False, + }, { "id": "alarm_control_panel.alarm", "name": {"name": "Alarm"}, diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index b021ef23391..aeb304cb1c8 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -3,7 +3,13 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.lock import DOMAIN -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -60,6 +66,27 @@ async def test_get_conditions(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_unlocking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_locking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_jammed", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] conditions = await async_get_device_automations(hass, "condition", device_entry.id) assert_lists_same(conditions, expected_conditions) @@ -110,6 +137,60 @@ async def test_if_state(hass, calls): }, }, }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_unlocking", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_unlocking - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event4"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_locking", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_locking - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event5"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_jammed", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_jammed - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, ] }, ) @@ -125,3 +206,21 @@ async def test_if_state(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_unlocked - event - test_event2" + + hass.states.async_set("lock.entity", STATE_UNLOCKING) + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "is_unlocking - event - test_event3" + + hass.states.async_set("lock.entity", STATE_LOCKING) + hass.bus.async_fire("test_event4") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "is_locking - event - test_event4" + + hass.states.async_set("lock.entity", STATE_JAMMED) + hass.bus.async_fire("test_event5") + await hass.async_block_till_done() + assert len(calls) == 5 + assert calls[4].data["some"] == "is_jammed - event - test_event5" diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index d4d96927b56..c3539288f94 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -5,7 +5,13 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.lock import DOMAIN -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -65,6 +71,27 @@ async def test_get_triggers(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "platform": "device", + "domain": DOMAIN, + "type": "unlocking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "locking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "jammed", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) assert_lists_same(triggers, expected_triggers) @@ -81,7 +108,7 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 2 + assert len(triggers) == 5 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( hass, "trigger", trigger @@ -195,7 +222,82 @@ async def test_if_fires_on_state_change_with_for(hass, calls): ) }, }, - } + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "unlocking", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "jammed", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "locking", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, ] }, ) @@ -214,3 +316,39 @@ async def test_if_fires_on_state_change_with_for(hass, calls): calls[0].data["some"] == f"turn_off device - {entity_id} - unlocked - locked - 0:00:05" ) + + hass.states.async_set(entity_id, STATE_UNLOCKING) + await hass.async_block_till_done() + assert len(calls) == 1 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=16)) + await hass.async_block_till_done() + assert len(calls) == 2 + await hass.async_block_till_done() + assert ( + calls[1].data["some"] + == f"turn_on device - {entity_id} - locked - unlocking - 0:00:05" + ) + + hass.states.async_set(entity_id, STATE_JAMMED) + await hass.async_block_till_done() + assert len(calls) == 2 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=21)) + await hass.async_block_till_done() + assert len(calls) == 3 + await hass.async_block_till_done() + assert ( + calls[2].data["some"] + == f"turn_off device - {entity_id} - unlocking - jammed - 0:00:05" + ) + + hass.states.async_set(entity_id, STATE_LOCKING) + await hass.async_block_till_done() + assert len(calls) == 3 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) + await hass.async_block_till_done() + assert len(calls) == 4 + await hass.async_block_till_done() + assert ( + calls[3].data["some"] + == f"turn_on device - {entity_id} - jammed - locking - 0:00:05" + ) From 193d1b945b0fad66f5adb0f8476b33a918e3c70c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 20 Jul 2021 18:28:31 +0200 Subject: [PATCH 007/112] Add typing in dynalite and activate mypy (#53238) Co-authored-by: Franck Nijhof --- homeassistant/components/dynalite/__init__.py | 3 ++- homeassistant/components/dynalite/bridge.py | 10 +++++----- homeassistant/components/dynalite/config_flow.py | 9 ++++++--- homeassistant/components/dynalite/convert_config.py | 5 ++++- homeassistant/components/dynalite/dynalitebase.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 703ac1373ba..7dc3d86afe6 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -52,6 +52,7 @@ from .const import ( SERVICE_REQUEST_AREA_PRESET, SERVICE_REQUEST_CHANNEL_LEVEL, ) +from .convert_config import convert_config def num_string(value: int | str) -> str: @@ -263,7 +264,7 @@ async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) - bridge = DynaliteBridge(hass, entry.data) + bridge = DynaliteBridge(hass, convert_config(entry.data)) # need to do it before the listener hass.data[DOMAIN][entry.entry_id] = bridge entry.async_on_unload(entry.add_update_listener(async_entry_changed)) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 71cecee8d43..9c911e6983d 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,6 +1,7 @@ """Code to handle a Dynalite bridge.""" from __future__ import annotations +from types import MappingProxyType from typing import Any, Callable from dynalite_devices_lib.dynalite_devices import ( @@ -27,9 +28,8 @@ class DynaliteBridge: def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Initialize the system based on host parameter.""" self.hass = hass - self.area = {} - self.async_add_devices = {} - self.waiting_devices = {} + self.async_add_devices: dict[str, Callable] = {} + self.waiting_devices: dict[str, list[str]] = {} self.host = config[CONF_HOST] # Configure the dynalite devices self.dynalite_devices = DynaliteDevices( @@ -37,7 +37,7 @@ class DynaliteBridge: update_device_func=self.update_device, notification_func=self.handle_notification, ) - self.dynalite_devices.configure(convert_config(config)) + self.dynalite_devices.configure(config) async def async_setup(self) -> bool: """Set up a Dynalite bridge.""" @@ -45,7 +45,7 @@ class DynaliteBridge: LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config: dict[str, Any]) -> None: + def reload_config(self, config: MappingProxyType[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(convert_config(config)) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index e1d062a6058..d148d09354f 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -8,6 +8,7 @@ from homeassistant.const import CONF_HOST from .bridge import DynaliteBridge from .const import DOMAIN, LOGGER +from .convert_config import convert_config class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -25,11 +26,13 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): host = import_info[CONF_HOST] for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: - if entry.data != import_info: - self.hass.config_entries.async_update_entry(entry, data=import_info) + self.hass.config_entries.async_update_entry( + entry, data=dict(import_info) + ) return self.async_abort(reason="already_configured") + # New entry - bridge = DynaliteBridge(self.hass, import_info) + bridge = DynaliteBridge(self.hass, convert_config(import_info)) if not await bridge.async_setup(): LOGGER.error("Unable to setup bridge - import info=%s", import_info) return self.async_abort(reason="no_connection") diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 89a7f32b47a..4abc02c0565 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -1,6 +1,7 @@ """Convert the HA config to the dynalite config.""" from __future__ import annotations +from types import MappingProxyType from typing import Any from dynalite_devices_lib import const as dyn_const @@ -136,7 +137,9 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: return convert_with_map(config, my_map) -def convert_config(config: dict[str, Any]) -> dict[str, Any]: +def convert_config( + config: dict[str, Any] | MappingProxyType[str, Any] +) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { CONF_NAME: dyn_const.CONF_NAME, diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index ebb1dd23795..56def12afbe 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -43,7 +43,7 @@ class DynaliteBase(Entity): """Initialize the base class.""" self._device = device self._bridge = bridge - self._unsub_dispatchers = [] + self._unsub_dispatchers: list[Callable[[], None]] = [] @property def name(self) -> str: diff --git a/mypy.ini b/mypy.ini index 4bfb8e22fe3..942ef115135 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1139,9 +1139,6 @@ ignore_errors = true [mypy-homeassistant.components.doorbird.*] ignore_errors = true -[mypy-homeassistant.components.dynalite.*] -ignore_errors = true - [mypy-homeassistant.components.edl21.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 720c9157e14..a571047446c 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -38,7 +38,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.dhcp.*", "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", - "homeassistant.components.dynalite.*", "homeassistant.components.edl21.*", "homeassistant.components.elkm1.*", "homeassistant.components.emonitor.*", From 1746103e0e977584f98b46b76c99b5a8b9b7afd9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 20 Jul 2021 18:38:16 +0200 Subject: [PATCH 008/112] Add friendly name to Fritz profile switches (#53190) --- homeassistant/components/fritz/switch.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 1100a480fc8..2969095d34d 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -557,20 +557,14 @@ class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): """Defines a FRITZ!Box Tools DeviceProfile switch.""" + _attr_icon = "mdi:router-wireless-settings" + def __init__(self, fritzbox_tools: FritzBoxTools, device: FritzDevice) -> None: """Init Fritz profile.""" super().__init__(fritzbox_tools, device) self._attr_is_on: bool = False - - @property - def unique_id(self) -> str: - """Return device unique id.""" - return f"{self._mac}_switch" - - @property - def icon(self) -> str: - """Return device icon.""" - return "mdi:router-wireless-settings" + self._name = f"{device.hostname} Internet Access" + self._attr_unique_id = f"{self._mac}_internet_access" async def async_process_update(self) -> None: """Update device.""" From 1ed7b00b7108fc12b36f1bb75eb6bd06ad6483d6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Jul 2021 09:39:14 -0700 Subject: [PATCH 009/112] Add last reset and state class to rainforest eagle (#52951) --- .../components/rainforest_eagle/sensor.py | 104 +++++++++--------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index d333f9437f1..53e94d2070e 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -1,5 +1,8 @@ """Support for the Rainforest Eagle-200 energy monitor.""" -from datetime import timedelta +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta import logging from eagle200_reader import EagleReader @@ -7,14 +10,19 @@ from requests.exceptions import ConnectionError as ConnectError, HTTPError, Time from uEagle import Eagle as LegacyReader import voluptuous as vol -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 ( CONF_IP_ADDRESS, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt CONF_CLOUD_ID = "cloud_id" CONF_INSTALL_CODE = "install_code" @@ -24,19 +32,43 @@ _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 + last_reset: datetime | None = None + + SENSORS = { - "instantanous_demand": ("Eagle-200 Meter Power Demand", POWER_KILO_WATT), - "summation_delivered": ( - "Eagle-200 Total Meter Energy Delivered", - ENERGY_KILO_WATT_HOUR, + "instantanous_demand": SensorType( + name="Eagle-200 Meter Power Demand", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "summation_received": ( - "Eagle-200 Total Meter Energy Received", - ENERGY_KILO_WATT_HOUR, + "summation_delivered": SensorType( + 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), ), - "summation_total": ( - "Eagle-200 Net Meter Energy (Delivered minus Received)", - ENERGY_KILO_WATT_HOUR, + "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), + ), + "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, ), } @@ -86,56 +118,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): eagle_data = EagleData(eagle_reader) eagle_data.update() - monitored_conditions = list(SENSORS) - sensors = [] - for condition in monitored_conditions: - sensors.append( - EagleSensor( - eagle_data, condition, SENSORS[condition][0], SENSORS[condition][1] - ) - ) - add_entities(sensors) + add_entities(EagleSensor(eagle_data, condition) for condition in SENSORS) class EagleSensor(SensorEntity): """Implementation of the Rainforest Eagle-200 sensor.""" - def __init__(self, eagle_data, sensor_type, name, unit): + def __init__(self, eagle_data, sensor_type): """Initialize the sensor.""" self.eagle_data = eagle_data self._type = sensor_type - self._name = name - self._unit_of_measurement = unit - self._state = None - - @property - def device_class(self): - """Return the power device class for the instantanous_demand sensor.""" - if self._type == "instantanous_demand": - return DEVICE_CLASS_POWER - - return None - - @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._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + sensor_info = SENSORS[sensor_type] + self._attr_name = sensor_info.name + self._attr_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.""" self.eagle_data.update() - self._state = self.eagle_data.get_state(self._type) + self._attr_state = self.eagle_data.get_state(self._type) class EagleData: From 165e1917ea6999fd6975fb7e6fd13c383c1414c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 20 Jul 2021 18:57:40 +0200 Subject: [PATCH 010/112] Address late review of Ambiclimate, code clean up (#53231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ambiclimate, code clean up Signed-off-by: Daniel Hjelseth Høyer * Update homeassistant/components/ambiclimate/climate.py Co-authored-by: Franck Nijhof * Update homeassistant/components/ambiclimate/climate.py Co-authored-by: Franck Nijhof * Update homeassistant/components/ambiclimate/config_flow.py Co-authored-by: Franck Nijhof * Update homeassistant/components/ambiclimate/climate.py Co-authored-by: Franck Nijhof * import Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: Franck Nijhof --- homeassistant/components/ambiclimate/climate.py | 13 +++++++------ homeassistant/components/ambiclimate/config_flow.py | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index a49253af6bc..8cfebb1bf69 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -1,6 +1,7 @@ """Support for Ambiclimate ac.""" import asyncio import logging +from typing import Any import ambiclimate import voluptuous as vol @@ -146,24 +147,24 @@ class AmbiclimateEntity(ClimateEntity): """Initialize the thermostat.""" self._heater = heater self._store = store - self._attr_unique_id = self._heater.device_id - self._attr_name = self._heater.name + self._attr_unique_id = heater.device_id + self._attr_name = heater.name self._attr_device_info = { "identifiers": {(DOMAIN, self.unique_id)}, "name": self.name, "manufacturer": "Ambiclimate", } - self._attr_min_temp = self._heater.get_min_temp() - self._attr_max_temp = self._heater.get_max_temp() + self._attr_min_temp = heater.get_min_temp() + self._attr_max_temp = heater.get_max_temp() - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return await self._heater.set_target_temperature(temperature) - async def async_set_hvac_mode(self, hvac_mode) -> None: + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: await self._heater.turn_on() diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 7f9ff9e5d09..2643b01185a 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Ambiclimate.""" import logging +from aiohttp import web import ambiclimate from homeassistant import config_entries @@ -139,7 +140,7 @@ class AmbiclimateAuthCallbackView(HomeAssistantView): url = AUTH_CALLBACK_PATH name = AUTH_CALLBACK_NAME - async def get(self, request) -> str: + async def get(self, request: web.Request) -> str: """Receive authorization token.""" code = request.query.get("code") if code is None: From 6e88428f958f4e8a93e90cdf20f4b57ccecc0aff Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 20 Jul 2021 13:31:55 -0400 Subject: [PATCH 011/112] Fix typing for climacell dataclass (#53240) --- 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 f19724b002e..c11c0b1774b 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -158,7 +158,7 @@ class ClimaCellSensorMetadata: name: str unit_imperial: str | None = None unit_metric: str | None = None - metric_conversion: Callable | float = 1.0 + metric_conversion: Callable[[float], float] | float = 1.0 is_metric_check: bool | None = None device_class: str | None = None value_map: IntEnum | None = None From 074d762664976d3414b8a33535e9b850d80a42fa Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 20 Jul 2021 20:06:23 +0200 Subject: [PATCH 012/112] Rename and reorganize electric unit constants (#53243) --- homeassistant/components/apcupsd/sensor.py | 36 ++++----- .../components/dsmr_reader/definitions.py | 16 ++-- homeassistant/components/elkm1/sensor.py | 4 +- homeassistant/components/envirophat/sensor.py | 10 +-- homeassistant/components/goalzero/const.py | 10 +-- .../components/greeneye_monitor/sensor.py | 4 +- .../components/growatt_server/sensor.py | 44 +++++------ homeassistant/components/homematic/sensor.py | 4 +- homeassistant/components/isy994/const.py | 4 +- homeassistant/components/juicenet/sensor.py | 8 +- homeassistant/components/keba/sensor.py | 4 +- homeassistant/components/lcn/const.py | 4 +- homeassistant/components/mysensors/sensor.py | 12 +-- homeassistant/components/nut/const.py | 77 ++++++++++++++----- homeassistant/components/onewire/const.py | 8 +- homeassistant/components/rfxtrx/__init__.py | 14 ++-- homeassistant/components/sense/sensor.py | 4 +- homeassistant/components/shelly/sensor.py | 10 +-- homeassistant/components/smappee/sensor.py | 19 +++-- .../components/smartthings/sensor.py | 4 +- .../components/solaredge_local/sensor.py | 24 ++++-- homeassistant/components/solarlog/const.py | 11 ++- homeassistant/components/starline/sensor.py | 4 +- homeassistant/components/subaru/sensor.py | 4 +- .../components/switcher_kis/sensor.py | 4 +- .../components/system_bridge/sensor.py | 4 +- homeassistant/components/tasmota/sensor.py | 12 +-- homeassistant/components/ted5000/sensor.py | 17 +++- homeassistant/components/tibber/sensor.py | 16 ++-- homeassistant/components/wallbox/const.py | 4 +- .../components/wirelesstag/__init__.py | 4 +- homeassistant/const.py | 12 +-- tests/components/onewire/const.py | 10 +-- 33 files changed, 244 insertions(+), 178 deletions(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 4748ae2476e..d30625ee793 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -8,15 +8,15 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_RESOURCES, DEVICE_CLASS_TEMPERATURE, - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, FREQUENCY_HERTZ, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, TEMP_CELSIUS, TIME_MINUTES, TIME_SECONDS, - VOLT, ) import homeassistant.helpers.config_validation as cv @@ -33,7 +33,7 @@ SENSOR_TYPES = { "badbatts": ["Bad Batteries", "", "mdi:information-outline", None], "battdate": ["Battery Replaced", "", "mdi:calendar-clock", None], "battstat": ["Battery Status", "", "mdi:information-outline", None], - "battv": ["Battery Voltage", VOLT, "mdi:flash", 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], @@ -46,33 +46,33 @@ SENSOR_TYPES = { "endapc": ["Date and Time", "", "mdi:calendar-clock", None], "extbatts": ["External Batteries", "", "mdi:information-outline", None], "firmware": ["Firmware Version", "", "mdi:information-outline", None], - "hitrans": ["Transfer High", VOLT, "mdi:flash", None], + "hitrans": ["Transfer High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "hostname": ["Hostname", "", "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], "linefreq": ["Line Frequency", FREQUENCY_HERTZ, "mdi:information-outline", None], - "linev": ["Input Voltage", VOLT, "mdi:flash", 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", VOLT, "mdi:flash", None], + "lotrans": ["Transfer Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "mandate": ["Manufacture Date", "", "mdi:calendar", None], "masterupd": ["Master Update", "", "mdi:information-outline", None], - "maxlinev": ["Input Voltage High", VOLT, "mdi:flash", None], + "maxlinev": ["Input Voltage High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "maxtime": ["Battery Timeout", "", "mdi:timer-off-outline", None], "mbattchg": ["Battery Shutdown", PERCENTAGE, "mdi:battery-alert", None], - "minlinev": ["Input Voltage Low", VOLT, "mdi:flash", None], + "minlinev": ["Input Voltage Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "mintimel": ["Shutdown Time", "", "mdi:timer-outline", None], "model": ["Model", "", "mdi:information-outline", None], - "nombattv": ["Battery Nominal Voltage", VOLT, "mdi:flash", None], - "nominv": ["Nominal Input Voltage", VOLT, "mdi:flash", None], - "nomoutv": ["Nominal Output Voltage", VOLT, "mdi:flash", 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", ELECTRICAL_VOLT_AMPERE, "mdi:flash", None], + "nomapnt": ["Nominal Apparent Power", POWER_VOLT_AMPERE, "mdi:flash", None], "numxfers": ["Transfer Count", "", "mdi:counter", None], - "outcurnt": ["Output Current", ELECTRICAL_CURRENT_AMPERE, "mdi:flash", None], - "outputv": ["Output Voltage", VOLT, "mdi:flash", 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], @@ -99,9 +99,9 @@ INFERRED_UNITS = { " Minutes": TIME_MINUTES, " Seconds": TIME_SECONDS, " Percent": PERCENTAGE, - " Volts": VOLT, - " Ampere": ELECTRICAL_CURRENT_AMPERE, - " Volt-Ampere": ELECTRICAL_VOLT_AMPERE, + " Volts": ELECTRIC_POTENTIAL_VOLT, + " Ampere": ELECTRIC_CURRENT_AMPERE, + " Volt-Ampere": POWER_VOLT_AMPERE, " Watts": POWER_WATT, " Hz": FREQUENCY_HERTZ, " C": TEMP_CELSIUS, diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index d403f84e9b9..51aaca24c02 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -7,10 +7,10 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_KILO_WATT, - VOLT, VOLUME_CUBIC_METERS, ) @@ -112,37 +112,37 @@ DEFINITIONS = { "name": "Current voltage L1", "enable_default": True, "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, + "unit": ELECTRIC_POTENTIAL_VOLT, }, "dsmr/reading/phase_voltage_l2": { "name": "Current voltage L2", "enable_default": True, "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, + "unit": ELECTRIC_POTENTIAL_VOLT, }, "dsmr/reading/phase_voltage_l3": { "name": "Current voltage L3", "enable_default": True, "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, + "unit": ELECTRIC_POTENTIAL_VOLT, }, "dsmr/reading/phase_power_current_l1": { "name": "Phase power current L1", "enable_default": True, "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, + "unit": ELECTRIC_CURRENT_AMPERE, }, "dsmr/reading/phase_power_current_l2": { "name": "Phase power current L2", "enable_default": True, "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, + "unit": ELECTRIC_CURRENT_AMPERE, }, "dsmr/reading/phase_power_current_l3": { "name": "Phase power current L3", "enable_default": True, "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, + "unit": ELECTRIC_CURRENT_AMPERE, }, "dsmr/reading/timestamp": { "name": "Telegram timestamp", diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 4a75ccb242e..8f26af545b7 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -9,7 +9,7 @@ from elkm1_lib.util import pretty_const, username import voluptuous as vol from homeassistant.components.sensor import SensorEntity -from homeassistant.const import VOLT +from homeassistant.const import ELECTRIC_POTENTIAL_VOLT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform @@ -255,7 +255,7 @@ class ElkZone(ElkSensor): if self._element.definition == ZoneType.TEMPERATURE.value: return self._temperature_unit if self._element.definition == ZoneType.ANALOG_ZONE.value: - return VOLT + return ELECTRIC_POTENTIAL_VOLT return None def _element_changed(self, element, changeset): diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index 4e10b5e65d1..9bca552326a 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -10,9 +10,9 @@ from homeassistant.const import ( CONF_DISPLAY_OPTIONS, CONF_NAME, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_VOLT, PRESSURE_HPA, TEMP_CELSIUS, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -37,10 +37,10 @@ SENSOR_TYPES = { "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", VOLT, "mdi:flash", None], - "voltage_1": ["voltage_1", VOLT, "mdi:flash", None], - "voltage_2": ["voltage_2", VOLT, "mdi:flash", None], - "voltage_3": ["voltage_3", VOLT, "mdi:flash", 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], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index 327a4f4833c..ce4b610b3f0 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -20,7 +20,8 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, PERCENTAGE, POWER_WATT, @@ -28,7 +29,6 @@ from homeassistant.const import ( TEMP_CELSIUS, TIME_MINUTES, TIME_SECONDS, - VOLT, ) ATTR_DEFAULT_ENABLED = "default_enabled" @@ -66,7 +66,7 @@ SENSOR_DICT = { "ampsIn": { ATTR_NAME: "Amps In", ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - ATTR_UNIT_OF_MEASUREMENT: ELECTRICAL_CURRENT_AMPERE, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_DEFAULT_ENABLED: False, }, @@ -80,7 +80,7 @@ SENSOR_DICT = { "ampsOut": { ATTR_NAME: "Amps Out", ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - ATTR_UNIT_OF_MEASUREMENT: ELECTRICAL_CURRENT_AMPERE, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_DEFAULT_ENABLED: False, }, @@ -101,7 +101,7 @@ SENSOR_DICT = { "volts": { ATTR_NAME: "Volts", ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, - ATTR_UNIT_OF_MEASUREMENT: VOLT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, ATTR_DEFAULT_ENABLED: False, }, "socPercent": { diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 0aa106e6801..fac11395c8b 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -5,11 +5,11 @@ from homeassistant.const import ( CONF_SENSOR_TYPE, CONF_TEMPERATURE_UNIT, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_VOLT, POWER_WATT, TIME_HOURS, TIME_MINUTES, TIME_SECONDS, - VOLT, ) from . import ( @@ -270,7 +270,7 @@ class VoltageSensor(GEMSensor): """Entity showing voltage.""" _attr_icon = VOLTAGE_ICON - _attr_unit_of_measurement = VOLT + _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT def __init__(self, monitor_serial_number, number, name): """Construct the entity.""" diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index c8921d9e514..78fd24623d8 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -21,14 +21,14 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, FREQUENCY_HERTZ, PERCENTAGE, POWER_KILO_WATT, POWER_WATT, TEMP_CELSIUS, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, dt @@ -84,13 +84,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_voltage_input_1": ( "Input 1 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv1", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_amperage_input_1": ( "Input 1 Amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "ipv1", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -102,13 +102,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_voltage_input_2": ( "Input 2 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv2", {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_amperage_input_2": ( "Input 2 Amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "ipv2", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -120,13 +120,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_voltage_input_3": ( "Input 3 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv3", {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_amperage_input_3": ( "Input 3 Amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "ipv3", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -144,13 +144,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_reactive_voltage": ( "Reactive voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vacr", {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_inverter_reactive_amperage": ( "Reactive amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "iacr", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -280,13 +280,13 @@ STORAGE_SENSOR_TYPES = { ), "storage_grid_voltage": ( "AC input voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vGrid", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), "storage_pv_charging_voltage": ( "PV charging voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -298,7 +298,7 @@ STORAGE_SENSOR_TYPES = { ), "storage_output_voltage": ( "Output voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "outPutVolt", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -310,31 +310,31 @@ STORAGE_SENSOR_TYPES = { ), "storage_current_PV": ( "Solar charge current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "iAcCharge", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_current_1": ( "Solar current to storage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "iChargePV1", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_grid_amperage_input": ( "Grid charge current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "chgCurr", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_grid_out_current": ( "Grid out current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "outPutCurrent", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_battery_voltage": ( "Battery voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vBat", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -398,19 +398,19 @@ MIX_SENSOR_TYPES = { ), "mix_battery_voltage": ( "Battery voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vbat", {"device_class": DEVICE_CLASS_VOLTAGE}, ), "mix_pv1_voltage": ( "PV1 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv1", {"device_class": DEVICE_CLASS_VOLTAGE}, ), "mix_pv2_voltage": ( "PV2 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv2", {"device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -490,7 +490,7 @@ MIX_SENSOR_TYPES = { ), "mix_grid_voltage": ( "Grid voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vAc1", {"device_class": DEVICE_CLASS_VOLTAGE}, ), diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 2bc51f67896..ad62001d5f9 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, ELECTRIC_CURRENT_MILLIAMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_MILLIMETERS, @@ -18,7 +19,6 @@ from homeassistant.const import ( PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - VOLT, VOLUME_CUBIC_METERS, ) @@ -49,7 +49,7 @@ HM_UNIT_HA_CAST = { "BRIGHTNESS": "#", "POWER": POWER_WATT, "CURRENT": ELECTRIC_CURRENT_MILLIAMPERE, - "VOLTAGE": VOLT, + "VOLTAGE": ELECTRIC_POTENTIAL_VOLT, "ENERGY_COUNTER": ENERGY_WATT_HOUR, "GAS_POWER": VOLUME_CUBIC_METERS, "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS, diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 03b1fa6c66b..343f01332f2 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -50,6 +50,7 @@ from homeassistant.const import ( DEGREE, ELECTRIC_CURRENT_MILLIAMPERE, ELECTRIC_POTENTIAL_MILLIVOLT, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, @@ -102,7 +103,6 @@ from homeassistant.const import ( TIME_SECONDS, TIME_YEARS, UV_INDEX, - VOLT, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, @@ -399,7 +399,7 @@ UOM_FRIENDLY_NAME = { "65": "SML", "69": VOLUME_GALLONS, "71": UV_INDEX, - "72": VOLT, + "72": ELECTRIC_POTENTIAL_VOLT, "73": POWER_WATT, "74": IRRADIATION_WATTS_PER_SQUARE_METER, "75": "weekday", diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 81fabf17eea..51792daf38c 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -6,12 +6,12 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, TIME_SECONDS, - VOLT, ) from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR @@ -25,10 +25,10 @@ SENSOR_TYPES = { DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, ], - "voltage": ["Voltage", VOLT, DEVICE_CLASS_VOLTAGE, None], + "voltage": ["Voltage", ELECTRIC_POTENTIAL_VOLT, DEVICE_CLASS_VOLTAGE, None], "amps": [ "Amps", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT, ], diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 836785490e8..2792246d71c 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -2,7 +2,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( DEVICE_CLASS_POWER, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, ) @@ -23,7 +23,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "Max Current", "max_current", "mdi:flash", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, ), KebaSensor( keba, diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index 3458c78f853..faef86dc70a 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -3,11 +3,11 @@ from itertools import product from homeassistant.const import ( DEGREE, + ELECTRIC_POTENTIAL_VOLT, PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, - VOLT, ) PLATFORMS = ["binary_sensor", "climate", "cover", "light", "scene", "sensor", "switch"] @@ -171,7 +171,7 @@ VAR_UNITS = [ "PERCENT", "PPM", "VOLT", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "AMPERE", "AMP", "A", diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index f16f9e09c41..f2908567a14 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -11,20 +11,20 @@ from homeassistant.const import ( DEGREE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_MILLIVOLT, - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_METERS, LIGHT_LUX, MASS_KILOGRAMS, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, SOUND_PRESSURE_DB, TEMP_CELSIUS, TEMP_FAHRENHEIT, - VOLT, VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant @@ -59,13 +59,13 @@ SENSORS: dict[str, list[str | None] | dict[str, list[str | None]]] = { "S_VIBRATION": [FREQUENCY_HERTZ, None, None], "S_LIGHT_LEVEL": [LIGHT_LUX, "mdi:white-balance-sunny", None], }, - "V_VOLTAGE": [VOLT, "mdi:flash", None], - "V_CURRENT": [ELECTRICAL_CURRENT_AMPERE, "mdi:flash-auto", None], + "V_VOLTAGE": [ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "V_CURRENT": [ELECTRIC_CURRENT_AMPERE, "mdi:flash-auto", None], "V_PH": ["pH", None, None], "V_ORP": [ELECTRIC_POTENTIAL_MILLIVOLT, None, None], "V_EC": [CONDUCTIVITY, None, None], "V_VAR": ["var", None, None], - "V_VA": [ELECTRICAL_VOLT_AMPERE, None, None], + "V_VA": [POWER_VOLT_AMPERE, None, None], } diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 890ac3697dd..1f5fecdd219 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -7,14 +7,14 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_VOLTAGE, ) from homeassistant.const import ( - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, FREQUENCY_HERTZ, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, TEMP_CELSIUS, TIME_SECONDS, - VOLT, ) DOMAIN = "nut" @@ -80,8 +80,8 @@ SENSOR_TYPES = { "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", ELECTRICAL_VOLT_AMPERE, "mdi:flash", None], - "ups.power.nominal": ["Nominal Power", ELECTRICAL_VOLT_AMPERE, "mdi:flash", 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, @@ -121,25 +121,40 @@ SENSOR_TYPES = { None, ], "battery.charger.status": ["Charging Status", "", "mdi:information-outline", None], - "battery.voltage": ["Battery Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], - "battery.voltage.nominal": [ - "Nominal Battery Voltage", - VOLT, + "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.voltage.low": ["Low Battery Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], - "battery.voltage.high": ["High Battery Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash", None], "battery.current": [ "Battery Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:flash", None, ], "battery.current.total": [ "Total Battery Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:flash", None, ], @@ -184,18 +199,33 @@ SENSOR_TYPES = { "mdi:information-outline", None, ], - "input.transfer.low": ["Low Voltage Transfer", VOLT, None, DEVICE_CLASS_VOLTAGE], - "input.transfer.high": ["High Voltage Transfer", VOLT, None, DEVICE_CLASS_VOLTAGE], + "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", VOLT, None, DEVICE_CLASS_VOLTAGE], + "input.voltage": [ + "Input Voltage", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], "input.voltage.nominal": [ "Nominal Input Voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, None, DEVICE_CLASS_VOLTAGE, ], @@ -212,17 +242,22 @@ SENSOR_TYPES = { "mdi:information-outline", None, ], - "output.current": ["Output Current", ELECTRICAL_CURRENT_AMPERE, "mdi:flash", None], + "output.current": ["Output Current", ELECTRIC_CURRENT_AMPERE, "mdi:flash", None], "output.current.nominal": [ "Nominal Output Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:flash", None, ], - "output.voltage": ["Output Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], + "output.voltage": [ + "Output Voltage", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], "output.voltage.nominal": [ "Nominal Output Voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, None, DEVICE_CLASS_VOLTAGE, ], diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index d2c712c26c5..9112bf5e8f6 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -11,12 +11,12 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, LIGHT_LUX, PERCENTAGE, PRESSURE_MBAR, TEMP_CELSIUS, - VOLT, ) CONF_MOUNT_DIR = "mount_dir" @@ -55,8 +55,8 @@ SENSOR_TYPES: dict[str, list[str | None]] = { SENSOR_TYPE_WETNESS: [PERCENTAGE, DEVICE_CLASS_HUMIDITY], SENSOR_TYPE_MOISTURE: [PRESSURE_CBAR, DEVICE_CLASS_PRESSURE], SENSOR_TYPE_COUNT: ["count", None], - SENSOR_TYPE_VOLTAGE: [VOLT, DEVICE_CLASS_VOLTAGE], - SENSOR_TYPE_CURRENT: [ELECTRICAL_CURRENT_AMPERE, DEVICE_CLASS_CURRENT], + 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], diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index a4be36df998..66d4235ffdb 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -23,7 +23,8 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, DEGREE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_STOP, LENGTH_MILLIMETERS, @@ -35,7 +36,6 @@ from homeassistant.const import ( SPEED_METERS_PER_SECOND, TEMP_CELSIUS, UV_INDEX, - VOLT, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -87,11 +87,11 @@ DATA_TYPES = OrderedDict( ("Wind gust", SPEED_METERS_PER_SECOND), ("Chill", TEMP_CELSIUS), ("Count", "count"), - ("Current Ch. 1", ELECTRICAL_CURRENT_AMPERE), - ("Current Ch. 2", ELECTRICAL_CURRENT_AMPERE), - ("Current Ch. 3", ELECTRICAL_CURRENT_AMPERE), - ("Voltage", VOLT), - ("Current", ELECTRICAL_CURRENT_AMPERE), + ("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), ] diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 238b0b83cde..dd522d012a5 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -3,9 +3,9 @@ from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntit from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_POWER, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, - VOLT, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -174,7 +174,7 @@ class SenseActiveSensor(SensorEntity): class SenseVoltageSensor(SensorEntity): """Implementation of a Sense energy voltage sensor.""" - _attr_unit_of_measurement = VOLT + _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON _attr_should_poll = False diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index a42f38f8a1b..8a435c3e50f 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -4,13 +4,13 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEGREE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - VOLT, ) from .const import SHAIR_MAX_WORK_HOURS @@ -43,7 +43,7 @@ SENSORS = { ), ("emeter", "current"): BlockAttributeDescription( name="Current", - unit=ELECTRICAL_CURRENT_AMPERE, + unit=ELECTRIC_CURRENT_AMPERE, value=lambda value: value, device_class=sensor.DEVICE_CLASS_CURRENT, state_class=sensor.STATE_CLASS_MEASUREMENT, @@ -72,7 +72,7 @@ SENSORS = { ), ("emeter", "voltage"): BlockAttributeDescription( name="Voltage", - unit=VOLT, + unit=ELECTRIC_POTENTIAL_VOLT, value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_VOLTAGE, state_class=sensor.STATE_CLASS_MEASUREMENT, @@ -186,7 +186,7 @@ SENSORS = { ), ("adc", "adc"): BlockAttributeDescription( name="ADC", - unit=VOLT, + unit=ELECTRIC_POTENTIAL_VOLT, value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_VOLTAGE, state_class=sensor.STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 75c5da85c34..fb00886f1f6 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,6 +1,11 @@ """Support for monitoring a Smappee energy sensor.""" from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, POWER_WATT, VOLT +from homeassistant.const import ( + DEVICE_CLASS_POWER, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + POWER_WATT, +) from .const import DOMAIN @@ -93,7 +98,7 @@ VOLTAGE_SENSORS = { "phase_voltages_a": [ "Phase voltages - A", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "phase_voltage_a", None, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], @@ -101,7 +106,7 @@ VOLTAGE_SENSORS = { "phase_voltages_b": [ "Phase voltages - B", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "phase_voltage_b", None, ["TWO", "THREE_STAR", "THREE_DELTA"], @@ -109,7 +114,7 @@ VOLTAGE_SENSORS = { "phase_voltages_c": [ "Phase voltages - C", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "phase_voltage_c", None, ["THREE_STAR"], @@ -117,7 +122,7 @@ VOLTAGE_SENSORS = { "line_voltages_a": [ "Line voltages - A", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "line_voltage_a", None, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], @@ -125,7 +130,7 @@ VOLTAGE_SENSORS = { "line_voltages_b": [ "Line voltages - B", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "line_voltage_b", None, ["TWO", "THREE_STAR", "THREE_DELTA"], @@ -133,7 +138,7 @@ VOLTAGE_SENSORS = { "line_voltages_c": [ "Line voltages - C", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "line_voltage_c", None, ["THREE_STAR", "THREE_DELTA"], diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index a7e2926036c..b8bb071fc0a 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, MASS_KILOGRAMS, @@ -22,7 +23,6 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, - VOLT, VOLUME_CUBIC_METERS, ) @@ -254,7 +254,7 @@ CAPABILITY_TO_SENSORS = { Map(Attribute.ultraviolet_index, "Ultraviolet Index", None, None) ], Capability.voltage_measurement: [ - Map(Attribute.voltage, "Voltage Measurement", VOLT, None) + Map(Attribute.voltage, "Voltage Measurement", ELECTRIC_POTENTIAL_VOLT, None) ], Capability.washer_mode: [Map(Attribute.washer_mode, "Washer Mode", None, None)], Capability.washer_operating_state: [ diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 920cbb564f8..3f159ce4480 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -14,13 +14,13 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_NAME, DEVICE_CLASS_TEMPERATURE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -44,8 +44,20 @@ INVERTER_MODES = ( # Supported sensor types: # Key: ['json_key', 'name', unit, icon, attribute name] SENSOR_TYPES = { - "current_AC_voltage": ["gridvoltage", "Grid Voltage", VOLT, "mdi:current-ac", None], - "current_DC_voltage": ["dcvoltage", "DC Voltage", VOLT, "mdi:current-dc", None], + "current_AC_voltage": [ + "gridvoltage", + "Grid Voltage", + ELECTRIC_POTENTIAL_VOLT, + "mdi:current-ac", + None, + ], + "current_DC_voltage": [ + "dcvoltage", + "DC Voltage", + ELECTRIC_POTENTIAL_VOLT, + "mdi:current-dc", + None, + ], "current_frequency": [ "gridfrequency", "Grid Frequency", @@ -113,7 +125,7 @@ SENSOR_TYPES = { "optimizer_current": [ "optimizercurrent", "Average Optimizer Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:solar-panel", None, None, @@ -137,7 +149,7 @@ SENSOR_TYPES = { "optimizer_voltage": [ "optimizervoltage", "Average Optimizer Voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "mdi:solar-panel", None, None, diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index dab844f86d6..0d989642d07 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -1,7 +1,12 @@ """Constants for the Solar-Log integration.""" from datetime import timedelta -from homeassistant.const import ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, VOLT +from homeassistant.const import ( + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, +) DOMAIN = "solarlog" @@ -17,8 +22,8 @@ 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", VOLT, "mdi:flash"], - "voltage_dc": ["voltageDC", "voltage DC", VOLT, "mdi:flash"], + "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", diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 4cb470be894..e7996befad3 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -1,10 +1,10 @@ """Reads vehicle status from StarLine API.""" from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE, SensorEntity from homeassistant.const import ( + ELECTRIC_POTENTIAL_VOLT, LENGTH_KILOMETERS, PERCENTAGE, TEMP_CELSIUS, - VOLT, VOLUME_LITERS, ) from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level @@ -14,7 +14,7 @@ from .const import DOMAIN from .entity import StarlineEntity SENSOR_TYPES = { - "battery": ["Battery", None, VOLT, None], + "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], diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 3994c9c6124..ff1d8b715d7 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -8,13 +8,13 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, LENGTH_KILOMETERS, LENGTH_MILES, PERCENTAGE, PRESSURE_HPA, TEMP_CELSIUS, TIME_MINUTES, - VOLT, VOLUME_GALLONS, VOLUME_LITERS, ) @@ -111,7 +111,7 @@ API_GEN_2_SENSORS = [ SENSOR_TYPE: "12V Battery Voltage", SENSOR_CLASS: DEVICE_CLASS_VOLTAGE, SENSOR_FIELD: sc.BATTERY_VOLTAGE, - SENSOR_UNITS: VOLT, + SENSOR_UNITS: ELECTRIC_POTENTIAL_VOLT, }, ] diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 58a32e69154..705c6f0a2b6 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ELECTRICAL_CURRENT_AMPERE, POWER_WATT +from homeassistant.const import ELECTRIC_CURRENT_AMPERE, POWER_WATT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -45,7 +45,7 @@ POWER_SENSORS = { ), "electric_current": AttributeDescription( name="Electric Current", - unit=ELECTRICAL_CURRENT_AMPERE, + unit=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index d73d85fca1f..71bb7030a11 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -14,10 +14,10 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, FREQUENCY_GIGAHERTZ, PERCENTAGE, TEMP_CELSIUS, - VOLT, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -205,7 +205,7 @@ class BridgeCpuVoltageSensor(BridgeSensor): "CPU Voltage", None, DEVICE_CLASS_VOLTAGE, - VOLT, + ELECTRIC_POTENTIAL_VOLT, False, ) diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 87b81322799..b756d656921 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -26,14 +26,15 @@ from homeassistant.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_CENTIMETERS, LIGHT_LUX, MASS_KILOGRAMS, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS, @@ -44,7 +45,6 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, - VOLT, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -134,8 +134,8 @@ SENSOR_UNIT_MAP = { hc.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, hc.CONCENTRATION_PARTS_PER_BILLION: CONCENTRATION_PARTS_PER_BILLION, hc.CONCENTRATION_PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, - hc.ELECTRICAL_CURRENT_AMPERE: ELECTRICAL_CURRENT_AMPERE, - hc.ELECTRICAL_VOLT_AMPERE: ELECTRICAL_VOLT_AMPERE, + hc.ELECTRICAL_CURRENT_AMPERE: ELECTRIC_CURRENT_AMPERE, + hc.ELECTRICAL_VOLT_AMPERE: POWER_VOLT_AMPERE, hc.ENERGY_KILO_WATT_HOUR: ENERGY_KILO_WATT_HOUR, hc.FREQUENCY_HERTZ: FREQUENCY_HERTZ, hc.LENGTH_CENTIMETERS: LENGTH_CENTIMETERS, @@ -152,7 +152,7 @@ SENSOR_UNIT_MAP = { hc.TEMP_CELSIUS: TEMP_CELSIUS, hc.TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, hc.TEMP_KELVIN: TEMP_KELVIN, - hc.VOLT: VOLT, + hc.VOLT: ELECTRIC_POTENTIAL_VOLT, } diff --git a/homeassistant/components/ted5000/sensor.py b/homeassistant/components/ted5000/sensor.py index 5c439651ed5..6732014c747 100644 --- a/homeassistant/components/ted5000/sensor.py +++ b/homeassistant/components/ted5000/sensor.py @@ -12,7 +12,13 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, POWER_WATT, VOLT +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + ELECTRIC_POTENTIAL_VOLT, + POWER_WATT, +) from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle @@ -47,7 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for mtu in gateway.data: dev.append(Ted5000Sensor(gateway, name, mtu, POWER_WATT)) - dev.append(Ted5000Sensor(gateway, name, mtu, VOLT)) + dev.append(Ted5000Sensor(gateway, name, mtu, ELECTRIC_POTENTIAL_VOLT)) add_entities(dev) return True @@ -60,7 +66,7 @@ class Ted5000Sensor(SensorEntity): def __init__(self, gateway, name, mtu, unit): """Initialize the sensor.""" - units = {POWER_WATT: "power", VOLT: "voltage"} + units = {POWER_WATT: "power", ELECTRIC_POTENTIAL_VOLT: "voltage"} self._gateway = gateway self._name = f"{name} mtu{mtu} {units[unit]}" self._mtu = mtu @@ -112,4 +118,7 @@ class Ted5000Gateway: power = int(doc["LiveData"]["Power"]["MTU%d" % mtu]["PowerNow"]) voltage = int(doc["LiveData"]["Voltage"]["MTU%d" % mtu]["VoltageNow"]) - self.data[mtu] = {POWER_WATT: power, VOLT: voltage / 10} + self.data[mtu] = { + POWER_WATT: power, + ELECTRIC_POTENTIAL_VOLT: voltage / 10, + } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 65f2f5e17a1..9ba175db57d 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -22,12 +22,12 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import ( - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS, - VOLT, ) from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady @@ -126,37 +126,37 @@ RT_SENSOR_MAP: dict[str, TibberSensorMetadata] = { "voltagePhase1": TibberSensorMetadata( "voltage phase1", DEVICE_CLASS_VOLTAGE, - VOLT, + ELECTRIC_POTENTIAL_VOLT, STATE_CLASS_MEASUREMENT, ), "voltagePhase2": TibberSensorMetadata( "voltage phase2", DEVICE_CLASS_VOLTAGE, - VOLT, + ELECTRIC_POTENTIAL_VOLT, STATE_CLASS_MEASUREMENT, ), "voltagePhase3": TibberSensorMetadata( "voltage phase3", DEVICE_CLASS_VOLTAGE, - VOLT, + ELECTRIC_POTENTIAL_VOLT, STATE_CLASS_MEASUREMENT, ), "currentL1": TibberSensorMetadata( "current L1", DEVICE_CLASS_CURRENT, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, STATE_CLASS_MEASUREMENT, ), "currentL2": TibberSensorMetadata( "current L2", DEVICE_CLASS_CURRENT, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, STATE_CLASS_MEASUREMENT, ), "currentL3": TibberSensorMetadata( "current L3", DEVICE_CLASS_CURRENT, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, STATE_CLASS_MEASUREMENT, ), "signalStrength": TibberSensorMetadata( diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 41996107ce0..c8044f6990a 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -3,7 +3,7 @@ from homeassistant.const import ( CONF_ICON, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, @@ -30,7 +30,7 @@ CONF_SENSOR_TYPES = { CONF_ICON: "mdi:ev-station", CONF_NAME: "Max Available Power", CONF_ROUND: 0, - CONF_UNIT_OF_MEASUREMENT: ELECTRICAL_CURRENT_AMPERE, + CONF_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, STATE_UNAVAILABLE: False, }, "charging_speed": { diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 5da19f54dcf..4e3ace38411 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -11,9 +11,9 @@ from homeassistant.const import ( ATTR_VOLTAGE, CONF_PASSWORD, CONF_USERNAME, + ELECTRIC_POTENTIAL_VOLT, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send @@ -276,7 +276,7 @@ class WirelessTagBaseSensor(Entity): """Return the state attributes.""" return { ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining * 100), - ATTR_VOLTAGE: f"{self._tag.battery_volts:.2f}{VOLT}", + ATTR_VOLTAGE: f"{self._tag.battery_volts:.2f}{ELECTRIC_POTENTIAL_VOLT}", ATTR_TAG_SIGNAL_STRENGTH: f"{self._tag.signal_strength}{SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range, ATTR_TAG_POWER_CONSUMPTION: f"{self._tag.power_consumption:.2f}{PERCENTAGE}", diff --git a/homeassistant/const.py b/homeassistant/const.py index 1d754b78b7c..13b94b799db 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -401,19 +401,19 @@ ATTR_TEMPERATURE: Final = "temperature" # Power units POWER_WATT: Final = "W" POWER_KILO_WATT: Final = "kW" - -# Voltage units -VOLT: Final = "V" +POWER_VOLT_AMPERE: Final = "VA" # Energy units ENERGY_WATT_HOUR: Final = "Wh" ENERGY_KILO_WATT_HOUR: Final = "kWh" -# Electrical units +# Electric_current units ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" -ELECTRICAL_CURRENT_AMPERE: Final = "A" -ELECTRICAL_VOLT_AMPERE: Final = "VA" +ELECTRIC_CURRENT_AMPERE: Final = "A" + +# Electric_potential units ELECTRIC_POTENTIAL_MILLIVOLT: Final = "mV" +ELECTRIC_POTENTIAL_VOLT: Final = "V" # Degree units DEGREE: Final = "°" diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 1eb2b4b390a..5c12571fc1e 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -14,14 +14,14 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, LIGHT_LUX, PERCENTAGE, PRESSURE_MBAR, STATE_OFF, STATE_ON, TEMP_CELSIUS, - VOLT, ) MOCK_OWPROXY_DEVICES = { @@ -347,7 +347,7 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/VAD", "injected_value": b" 2.97", "result": "3.0", - "unit": VOLT, + "unit": ELECTRIC_POTENTIAL_VOLT, "class": DEVICE_CLASS_VOLTAGE, "disabled": True, }, @@ -356,7 +356,7 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/VDD", "injected_value": b" 4.74", "result": "4.7", - "unit": VOLT, + "unit": ELECTRIC_POTENTIAL_VOLT, "class": DEVICE_CLASS_VOLTAGE, "disabled": True, }, @@ -365,7 +365,7 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/IAD", "injected_value": b" 1", "result": "1.0", - "unit": ELECTRICAL_CURRENT_AMPERE, + "unit": ELECTRIC_CURRENT_AMPERE, "class": DEVICE_CLASS_CURRENT, "disabled": True, }, From a05392fbf287c95fdd6d83ef0d536ead147bf555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 20 Jul 2021 20:07:06 +0200 Subject: [PATCH 013/112] Tibber, remove yaml support (#53235) --- homeassistant/components/tibber/__init__.py | 26 +++---------------- .../components/tibber/config_flow.py | 4 --- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index d575a520cb2..a18bb855f8f 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -4,10 +4,8 @@ import logging import aiohttp import tibber -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -20,13 +18,7 @@ PLATFORMS = [ "sensor", ] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.deprecated(DOMAIN) _LOGGER = logging.getLogger(__name__) @@ -35,18 +27,6 @@ async def async_setup(hass, config): """Set up the Tibber component.""" hass.data[DATA_HASS_CONFIG] = config - - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - return True @@ -82,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, {CONF_NAME: DOMAIN}, hass.data[DATA_HASS_CONFIG] + hass, "notify", DOMAIN, {}, hass.data[DATA_HASS_CONFIG] ) ) return True diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index 1c1cef88776..4e804225c56 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -19,10 +19,6 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_info): - """Set the config entry up from yaml.""" - return await self.async_step_user(import_info) - async def async_step_user(self, user_input=None): """Handle the initial step.""" From 6be30b0289371bf71915ae31246ef37d7d34a45e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 20 Jul 2021 20:08:39 +0200 Subject: [PATCH 014/112] Use unit constants (#53244) * Powerwall - use POWER_KILO_WATT constant * Use constants firtz sensor --- homeassistant/components/fritz/sensor.py | 22 ++++++++++++-------- homeassistant/components/powerwall/const.py | 2 -- homeassistant/components/powerwall/sensor.py | 10 ++++++--- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 5820be138b4..031f7bc555c 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -10,7 +10,11 @@ from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.const import ( + DATA_GIGABYTES, + DATA_RATE_KILOBYTES_PER_SECOND, + DEVICE_CLASS_TIMESTAMP, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow @@ -97,38 +101,38 @@ SENSOR_DATA = { "kb_s_sent": SensorData( name="kB/s sent", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement="kB/s", + 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", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement="kB/s", + 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 kb/s sent", - unit_of_measurement="kb/s", + name="Max kB/s sent", + unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:upload", state_provider=_retrieve_max_kb_s_sent_state, ), "max_kb_s_received": SensorData( - name="Max kb/s received", - unit_of_measurement="kb/s", + name="Max kB/s received", + unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:download", state_provider=_retrieve_max_kb_s_received_state, ), "gb_sent": SensorData( name="GB sent", - unit_of_measurement="GB", + unit_of_measurement=DATA_GIGABYTES, icon="mdi:upload", state_provider=_retrieve_gb_sent_state, ), "gb_received": SensorData( name="GB received", - unit_of_measurement="GB", + unit_of_measurement=DATA_GIGABYTES, icon="mdi:download", state_provider=_retrieve_gb_received_state, ), diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index f338d5f981d..c86333cb9f8 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -32,5 +32,3 @@ POWERWALL_HTTP_SESSION = "http_session" MODEL = "PowerWall 2" MANUFACTURER = "Tesla" - -ENERGY_KILO_WATT = "kW" diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index d6c326593aa..d536c776bf0 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -4,7 +4,12 @@ import logging from tesla_powerwall import MeterType from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity -from homeassistant.const import DEVICE_CLASS_BATTERY, DEVICE_CLASS_POWER, PERCENTAGE +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_POWER, + PERCENTAGE, + POWER_KILO_WATT, +) from .const import ( ATTR_ENERGY_EXPORTED, @@ -14,7 +19,6 @@ from .const import ( ATTR_INSTANT_TOTAL_CURRENT, ATTR_IS_ACTIVE, DOMAIN, - ENERGY_KILO_WATT, POWERWALL_API_CHARGE, POWERWALL_API_DEVICE_TYPE, POWERWALL_API_METERS, @@ -83,7 +87,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Energy sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = ENERGY_KILO_WATT + _attr_unit_of_measurement = POWER_KILO_WATT _attr_device_class = DEVICE_CLASS_POWER def __init__( From a14bde8187016bceba77abf100821e36bc733da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 20 Jul 2021 20:18:09 +0200 Subject: [PATCH 015/112] Melcloud use NamedTuple (#53234) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/melcloud/sensor.py | 188 ++++++++++---------- 1 file changed, 98 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index c1b7e5e8cbd..b0f1d73f238 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,4 +1,8 @@ """Support for MelCloud device sensors.""" +from __future__ import annotations + +from typing import Any, Callable, NamedTuple + from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW from pymelcloud.atw_device import Zone @@ -8,83 +12,85 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, ) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ENERGY_KILO_WATT_HOUR, - TEMP_CELSIUS, -) +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 -ATTR_MEASUREMENT_NAME = "measurement_name" -ATTR_UNIT = "unit" -ATTR_VALUE_FN = "value_fn" -ATTR_ENABLED_FN = "enabled" -ATA_SENSORS = { - "room_temperature": { - ATTR_MEASUREMENT_NAME: "Room Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x.device.room_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "energy": { - ATTR_MEASUREMENT_NAME: "Energy", - ATTR_ICON: "mdi:factory", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_VALUE_FN: lambda x: x.device.total_energy_consumed, - ATTR_ENABLED_FN: lambda x: x.device.has_energy_consumed_meter, - }, +class SensorMetadata(NamedTuple): + """Metadata for an individual sensor.""" + + measurement_name: str + icon: str + unit: str + device_class: str + value_fn: Callable[[Any], float] + enabled: Callable[[Any], bool] + + +ATA_SENSORS: dict[str, SensorMetadata] = { + "room_temperature": SensorMetadata( + "Room Temperature", + icon="mdi:thermometer", + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda x: x.device.room_temperature, + enabled=lambda x: True, + ), + "energy": SensorMetadata( + "Energy", + icon="mdi:factory", + unit=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, + ), } -ATW_SENSORS = { - "outside_temperature": { - ATTR_MEASUREMENT_NAME: "Outside Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x.device.outside_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "tank_temperature": { - ATTR_MEASUREMENT_NAME: "Tank Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x.device.tank_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, +ATW_SENSORS: dict[str, SensorMetadata] = { + "outside_temperature": SensorMetadata( + "Outside Temperature", + icon="mdi:thermometer", + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda x: x.device.outside_temperature, + enabled=lambda x: True, + ), + "tank_temperature": SensorMetadata( + "Tank Temperature", + icon="mdi:thermometer", + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda x: x.device.tank_temperature, + enabled=lambda x: True, + ), } -ATW_ZONE_SENSORS = { - "room_temperature": { - ATTR_MEASUREMENT_NAME: "Room Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda zone: zone.room_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "flow_temperature": { - ATTR_MEASUREMENT_NAME: "Flow Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda zone: zone.flow_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "return_temperature": { - ATTR_MEASUREMENT_NAME: "Flow Return Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda zone: zone.return_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, +ATW_ZONE_SENSORS: dict[str, SensorMetadata] = { + "room_temperature": SensorMetadata( + "Room Temperature", + icon="mdi:thermometer", + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda zone: zone.room_temperature, + enabled=lambda x: True, + ), + "flow_temperature": SensorMetadata( + "Flow Temperature", + icon="mdi:thermometer", + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda zone: zone.flow_temperature, + enabled=lambda x: True, + ), + "return_temperature": SensorMetadata( + "Flow Return Temperature", + icon="mdi:thermometer", + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda zone: zone.return_temperature, + enabled=lambda x: True, + ), } @@ -93,23 +99,23 @@ async def async_setup_entry(hass, entry, async_add_entities): mel_devices = hass.data[DOMAIN].get(entry.entry_id) async_add_entities( [ - MelDeviceSensor(mel_device, measurement, definition) - for measurement, definition in ATA_SENSORS.items() + MelDeviceSensor(mel_device, measurement, metadata) + for measurement, metadata in ATA_SENSORS.items() for mel_device in mel_devices[DEVICE_TYPE_ATA] - if definition[ATTR_ENABLED_FN](mel_device) + if metadata.enabled(mel_device) ] + [ - MelDeviceSensor(mel_device, measurement, definition) - for measurement, definition in ATW_SENSORS.items() + MelDeviceSensor(mel_device, measurement, metadata) + for measurement, metadata in ATW_SENSORS.items() for mel_device in mel_devices[DEVICE_TYPE_ATW] - if definition[ATTR_ENABLED_FN](mel_device) + if metadata.enabled(mel_device) ] + [ - AtwZoneSensor(mel_device, zone, measurement, definition) + AtwZoneSensor(mel_device, zone, measurement, metadata) for mel_device in mel_devices[DEVICE_TYPE_ATW] for zone in mel_device.device.zones - for measurement, definition, in ATW_ZONE_SENSORS.items() - if definition[ATTR_ENABLED_FN](zone) + for measurement, metadata, in ATW_ZONE_SENSORS.items() + if metadata.enabled(zone) ], True, ) @@ -118,25 +124,25 @@ async def async_setup_entry(hass, entry, async_add_entities): class MelDeviceSensor(SensorEntity): """Representation of a Sensor.""" - def __init__(self, api: MelCloudDevice, measurement, definition): + def __init__(self, api: MelCloudDevice, measurement, metadata: SensorMetadata): """Initialize the sensor.""" self._api = api - self._def = definition + self._metadata = metadata - self._attr_device_class = definition[ATTR_DEVICE_CLASS] - self._attr_icon = definition[ATTR_ICON] - self._attr_name = f"{api.name} {definition[ATTR_MEASUREMENT_NAME]}" + self._attr_device_class = metadata.device_class + self._attr_icon = metadata.icon + self._attr_name = f"{api.name} {metadata.measurement_name}" self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{measurement}" - self._attr_unit_of_measurement = definition[ATTR_UNIT] + self._attr_unit_of_measurement = metadata.unit self._attr_state_class = STATE_CLASS_MEASUREMENT - if self.device_class == DEVICE_CLASS_ENERGY: + if metadata.device_class == DEVICE_CLASS_ENERGY: self._attr_last_reset = dt_util.utc_from_timestamp(0) @property def state(self): """Return the state of the sensor.""" - return self._def[ATTR_VALUE_FN](self._api) + return self._metadata.value_fn(self._api) async def async_update(self): """Retrieve latest state.""" @@ -151,17 +157,19 @@ class MelDeviceSensor(SensorEntity): class AtwZoneSensor(MelDeviceSensor): """Air-to-Air device sensor.""" - def __init__(self, api: MelCloudDevice, zone: Zone, measurement, definition): + def __init__( + self, api: MelCloudDevice, zone: Zone, measurement, metadata: SensorMetadata + ): """Initialize the sensor.""" if zone.zone_index == 1: full_measurement = measurement else: full_measurement = f"{measurement}-zone-{zone.zone_index}" - super().__init__(api, full_measurement, definition) + super().__init__(api, full_measurement, metadata) self._zone = zone - self._attr_name = f"{api.name} {zone.name} {definition[ATTR_MEASUREMENT_NAME]}" + self._attr_name = f"{api.name} {zone.name} {metadata.measurement_name}" @property def state(self): """Return zone based state.""" - return self._def[ATTR_VALUE_FN](self._zone) + return self._metadata.value_fn(self._zone) From 8c43e5c7364ac85cfedd221456ef0ea0e01c6eb7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 20 Jul 2021 20:19:26 +0200 Subject: [PATCH 016/112] Correct set_temperature in modbus climate (#52923) --- homeassistant/components/modbus/climate.py | 33 ++++++++++---- tests/components/modbus/test_climate.py | 50 +++++++++++++++++++++- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 75a1f846c76..36871c68d9b 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -33,7 +33,12 @@ from .const import ( CONF_MIN_TEMP, CONF_STEP, CONF_TARGET_TEMP, - DEFAULT_STRUCT_FORMAT, + DATA_TYPE_INT16, + DATA_TYPE_INT32, + DATA_TYPE_INT64, + DATA_TYPE_UINT16, + DATA_TYPE_UINT32, + DATA_TYPE_UINT64, MODBUS_DOMAIN, ) from .modbus import ModbusHub @@ -145,16 +150,28 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: return - target_temperature = int( - (kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale - ) - byte_string = struct.pack(self._structure, target_temperature) - struct_string = f">{DEFAULT_STRUCT_FORMAT[self._data_type]}" - register_value = struct.unpack(struct_string, byte_string)[0] + target_temperature = ( + float(kwargs.get(ATTR_TEMPERATURE)) - self._offset + ) / self._scale + 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 = [ + int.from_bytes(as_bytes[i : i + 2], "big") + for i in range(0, len(as_bytes), 2) + ] + registers = self._swap_registers(raw_regs) result = await self._hub.async_pymodbus_call( self._slave, self._target_temperature_register, - register_value, + registers, CALL_TYPE_WRITE_REGISTERS, ) self._available = result is not None diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index b58822644be..b872f4fe302 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -3,7 +3,15 @@ import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import HVAC_MODE_AUTO -from homeassistant.components.modbus.const import CONF_CLIMATES, CONF_TARGET_TEMP +from homeassistant.components.modbus.const import ( + CONF_CLIMATES, + CONF_DATA_TYPE, + CONF_TARGET_TEMP, + DATA_TYPE_FLOAT32, + DATA_TYPE_FLOAT64, + DATA_TYPE_INT16, + DATA_TYPE_INT32, +) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_ADDRESS, @@ -110,6 +118,46 @@ async def test_service_climate_update(hass, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == "auto" +@pytest.mark.parametrize( + "data_type, temperature, result", + [ + (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]), + ], +) +async def test_service_climate_set_temperature( + hass, data_type, temperature, result, mock_pymodbus +): + """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, + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_temperature", + { + "entity_id": ENTITY_ID, + ATTR_TEMPERATURE: temperature, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} From a2fbc4218de269b545e59dde338a8d4afb7a30d9 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 20 Jul 2021 13:21:48 -0500 Subject: [PATCH 017/112] Cleanup regroup handling in Sonos (#53241) Check event before creating coroutine Remove unnecessary regrouping dispatcher Update typing to reflect actual behavior Add optimizations for polling mode --- homeassistant/components/sonos/__init__.py | 16 +------- homeassistant/components/sonos/const.py | 1 - homeassistant/components/sonos/speaker.py | 44 +++++++++++----------- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index ec26373c44e..26541db5fd8 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -19,11 +19,7 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOSTS, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send @@ -35,7 +31,6 @@ from .const import ( DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, - SONOS_GROUP_UPDATE, SONOS_REBOOTED, SONOS_SEEN, UPNP_ST, @@ -226,10 +221,6 @@ class SonosDiscoveryManager: DISCOVERY_INTERVAL.total_seconds(), self._manual_hosts ) - @callback - def _async_signal_update_groups(self, _event): - async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE) - def _discovered_ip(self, ip_address): soco = _create_soco(ip_address, SoCoCreationSource.DISCOVERED) if soco and soco.is_visible: @@ -290,11 +281,6 @@ class SonosDiscoveryManager: for platform in PLATFORMS ) ) - self.entry.async_on_unload( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_signal_update_groups - ) - ) self.entry.async_on_unload( self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self._async_stop_event_listener diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index aca4b9b39ae..88b71066486 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -140,7 +140,6 @@ SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_POLL_UPDATE = "sonos_poll_update" -SONOS_GROUP_UPDATE = "sonos_group_update" SONOS_ALARMS_UPDATED = "sonos_alarms_updated" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" SONOS_STATE_UPDATED = "sonos_state_updated" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 7522f72b20b..3ff6627bb8a 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -46,7 +46,6 @@ from .const import ( SONOS_CREATE_BATTERY, SONOS_CREATE_MEDIA_PLAYER, SONOS_ENTITY_CREATED, - SONOS_GROUP_UPDATE, SONOS_POLL_UPDATE, SONOS_REBOOTED, SONOS_SEEN, @@ -206,11 +205,6 @@ class SonosSpeaker: f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.async_handle_new_entity, ) - self._group_dispatcher = dispatcher_connect( - self.hass, - SONOS_GROUP_UPDATE, - self.async_update_groups, - ) self._seen_dispatcher = dispatcher_connect( self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen ) @@ -612,22 +606,18 @@ class SonosSpeaker: # # Group management # - def update_groups(self, event: SonosEvent | None = None) -> None: - """Handle callback for topology change event.""" - coro = self.create_update_groups_coro(event) - if coro: - self.hass.add_job(coro) # type: ignore + def update_groups(self) -> None: + """Update group topology when polling.""" + self.hass.add_job(self.create_update_groups_coro()) @callback - def async_update_groups(self, event: SonosEvent | None = None) -> None: + def async_update_groups(self, event: SonosEvent) -> None: """Handle callback for topology change event.""" - coro = self.create_update_groups_coro(event) - if coro: - self.hass.async_add_job(coro) # type: ignore + if not hasattr(event, "zone_player_uui_ds_in_group"): + return None + self.hass.async_add_job(self.create_update_groups_coro(event)) - def create_update_groups_coro( - self, event: SonosEvent | None = None - ) -> Coroutine | None: + def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine: """Handle callback for topology change event.""" def _get_soco_group() -> list[str]: @@ -646,7 +636,7 @@ class SonosSpeaker: return [coordinator_uid] + slave_uids - async def _async_extract_group(event: SonosEvent) -> list[str]: + async def _async_extract_group(event: SonosEvent | None) -> list[str]: """Extract group layout from a topology event.""" group = event and event.zone_player_uui_ds_in_group if group: @@ -658,6 +648,10 @@ class SonosSpeaker: @callback def _async_regroup(group: list[str]) -> None: """Rebuild internal group layout.""" + if group == [self.soco.uid] and self.sonos_group == [self]: + # Skip updating existing single speakers in polling mode + return + entity_registry = ent_reg.async_get(self.hass) sonos_group = [] sonos_group_entities = [] @@ -671,6 +665,11 @@ class SonosSpeaker: ) sonos_group_entities.append(entity_id) + if self.sonos_group_entities == sonos_group_entities: + # Useful in polling mode for speakers with stereo pairs or surrounds + # as those "invisible" speakers will bypass the single speaker check + return + self.coordinator = None self.sonos_group = sonos_group self.sonos_group_entities = sonos_group_entities @@ -684,7 +683,9 @@ class SonosSpeaker: slave.sonos_group_entities = sonos_group_entities slave.async_write_entity_states() - async def _async_handle_group_event(event: SonosEvent) -> None: + _LOGGER.debug("Regrouped %s: %s", self.zone_name, self.sonos_group_entities) + + async def _async_handle_group_event(event: SonosEvent | None) -> None: """Get async lock and handle event.""" async with self.hass.data[DATA_SONOS].topology_condition: @@ -695,9 +696,6 @@ class SonosSpeaker: self.hass.data[DATA_SONOS].topology_condition.notify_all() - if event and not hasattr(event, "zone_player_uui_ds_in_group"): - return None - return _async_handle_group_event(event) @soco_error() From 5ccbac5ff6ed7a8de330b718bb794467c7d46763 Mon Sep 17 00:00:00 2001 From: PeteRager <76050312+PeteRager@users.noreply.github.com> Date: Tue, 20 Jul 2021 14:23:22 -0400 Subject: [PATCH 018/112] Fix alert infinite loop on repeat interval of 0 (#52628) * #4851 - Infinite loop on repeat interval of 0 Notification will enter an infinite loop when the repeat interval is specified as zero and it is the last repeat configured. When this occurs avoid the infinite loop and log a warning message. Note: I encountered this issue when routing SMS to Twilio and quickly sent thousands of text messages. * Update __init__.py * Remove runtime check since configuration input is now blocked * Tweak comment Co-authored-by: Franck Nijhof --- homeassistant/components/alert/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 69edb3ee001..73be34e6d33 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -48,7 +48,12 @@ ALERT_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE, default=STATE_ON): cv.string, - vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), + vol.Required(CONF_REPEAT): vol.All( + cv.ensure_list, + [vol.Coerce(float)], + # Minimum delay is 1 second = 0.016 minutes + [vol.Range(min=0.016)], + ), vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, vol.Optional(CONF_ALERT_MESSAGE): cv.template, From 034251f0069e5ee670edadf4c5e732607728bbad Mon Sep 17 00:00:00 2001 From: web-dc <49026778+web-dc@users.noreply.github.com> Date: Tue, 20 Jul 2021 20:28:43 +0200 Subject: [PATCH 019/112] Update requirement of homematicip_cloud component to v1.0.1 (#51407) Co-authored-by: Franck Nijhof Co-authored-by: Sascha Schiegg --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/fixtures/homematicip_cloud.json | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index f82e2c19996..b41c7b06c74 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -3,7 +3,7 @@ "name": "HomematicIP Cloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", - "requirements": ["homematicip==0.13.1"], + "requirements": ["homematicip==1.0.1"], "codeowners": [], "quality_scale": "platinum", "iot_class": "cloud_push" diff --git a/requirements_all.txt b/requirements_all.txt index 1a79b9d76d4..a7ce60b1bff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -784,7 +784,7 @@ homeassistant-pyozw==0.1.10 homeconnect==0.6.3 # homeassistant.components.homematicip_cloud -homematicip==0.13.1 +homematicip==1.0.1 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04835c3d369..b9f4b83d37f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ homeassistant-pyozw==0.1.10 homeconnect==0.6.3 # homeassistant.components.homematicip_cloud -homematicip==0.13.1 +homematicip==1.0.1 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index 4579fad30ba..8462295cbc1 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -7037,7 +7037,7 @@ "dutyCycle": false, "homeId": "00000000-0000-0000-0000-000000000001", "id": "00000000-0000-0000-0000-000000000016", - "ignorableDevices": [], + "ignorableDeviceChannels": [], "label": "INTERNAL", "lastStatusUpdate": 1524515489257, "lowBat": false, @@ -7373,7 +7373,7 @@ "dutyCycle": false, "homeId": "00000000-0000-0000-0000-000000000001", "id": "00000000-0000-0000-0000-000000000005", - "ignorableDevices": [], + "ignorableDeviceChannels": [], "label": "EXTERNAL", "lastStatusUpdate": 1524516526498, "lowBat": false, @@ -8363,7 +8363,10 @@ "activationInProgress": false, "active": true, "alarmActive": false, - "alarmEventDeviceId": "3014F7110000000000000007", + "alarmEventDeviceChannel": { + "channelIndex": 1, + "deviceId": "3014F7110000000000000007" + }, "alarmEventTimestamp": 1524504122047, "alarmSecurityJournalEntryType": "SENSOR_EVENT", "functionalGroups": [ From 059a9bc8ed97bd3067ad912cca2189814c358eba Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 20 Jul 2021 22:03:10 +0200 Subject: [PATCH 020/112] Fix modbus setting string as temperature in climate platform (#53249) --- homeassistant/components/modbus/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 36871c68d9b..e8f281bdd1f 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -207,4 +207,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self.unpack_structure_result(result.registers) self._available = True - return self._value + + if self._value is None: + return None + return float(self._value) From a9b9c4f13c25b1213ccc37b8f15dd9ebe39cefa6 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 20 Jul 2021 16:26:52 -0400 Subject: [PATCH 021/112] Add extra state attributes to goalzero (#52932) * Add extra state attributes to goalzero * tweak --- homeassistant/components/goalzero/__init__.py | 12 ++++++++++-- homeassistant/components/goalzero/const.py | 9 +++++++++ homeassistant/components/goalzero/sensor.py | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index f3889d1d385..fe2d5cc695e 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSO from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -17,7 +17,13 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN, MIN_TIME_BETWEEN_UPDATES +from .const import ( + ATTRIBUTION, + DATA_KEY_API, + DATA_KEY_COORDINATOR, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, +) _LOGGER = logging.getLogger(__name__) @@ -74,6 +80,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class YetiEntity(CoordinatorEntity): """Representation of a Goal Zero Yeti entity.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__(self, api, coordinator, name, server_unique_id): """Initialize a Goal Zero Yeti entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index ce4b610b3f0..4c860a5e0e4 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -31,6 +31,7 @@ from homeassistant.const import ( TIME_SECONDS, ) +ATTRIBUTION = "Data provided by Goal Zero" ATTR_DEFAULT_ENABLED = "default_enabled" CONF_IDENTIFIERS = "identifiers" @@ -133,6 +134,14 @@ SENSOR_DICT = { 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 = { diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 8464104a61f..ccb39ae0813 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -56,4 +56,4 @@ class YetiSensor(YetiEntity): def state(self): """Return the state.""" if self.api.data: - return self.api.data[self._condition] + return self.api.data.get(self._condition) From 0b8b45818df26a872ee947f03f49007e6e8d53e4 Mon Sep 17 00:00:00 2001 From: jtitley Date: Wed, 21 Jul 2021 06:57:47 +1000 Subject: [PATCH 022/112] Update BlinkStick to 1.2.0 (#52244) Co-authored-by: Franck Nijhof --- homeassistant/components/blinksticklight/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json index 2520d2b1fcc..05f8fe65fb3 100644 --- a/homeassistant/components/blinksticklight/manifest.json +++ b/homeassistant/components/blinksticklight/manifest.json @@ -2,7 +2,7 @@ "domain": "blinksticklight", "name": "BlinkStick", "documentation": "https://www.home-assistant.io/integrations/blinksticklight", - "requirements": ["blinkstick==1.1.8"], + "requirements": ["blinkstick==1.2.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index a7ce60b1bff..76f3f940306 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -373,7 +373,7 @@ blebox_uniapi==1.3.3 blinkpy==0.17.0 # homeassistant.components.blinksticklight -blinkstick==1.1.8 +blinkstick==1.2.0 # homeassistant.components.blinkt # blinkt==0.1.0 From 0fd88e7e666ddbd31fbd0aaea15eb0dff35cc129 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 20 Jul 2021 15:41:03 -0600 Subject: [PATCH 023/112] Type _attr_extra_state_attributes as a MutableMapping (#52616) * Type extra_state_attributes as a MutableMapping * Update homeassistant/helpers/entity.py Co-authored-by: Ruslan Sayfutdinov * Update homeassistant/helpers/entity.py Co-authored-by: Ruslan Sayfutdinov --- homeassistant/helpers/entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 187d53ea00b..a50afd410e9 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC import asyncio -from collections.abc import Awaitable, Iterable, Mapping +from collections.abc import Awaitable, Iterable, Mapping, MutableMapping from datetime import datetime, timedelta import functools as ft import logging @@ -227,7 +227,7 @@ class Entity(ABC): _attr_device_info: DeviceInfo | None = None _attr_entity_picture: str | None = None _attr_entity_registry_enabled_default: bool = True - _attr_extra_state_attributes: Mapping[str, Any] | None = None + _attr_extra_state_attributes: MutableMapping[str, Any] | None = None _attr_force_update: bool = False _attr_icon: str | None = None _attr_name: str | None = None From 6ee82e103188b46d5ec4085e26c015d6f3a43c88 Mon Sep 17 00:00:00 2001 From: Brett Date: Wed, 21 Jul 2021 09:38:50 +1000 Subject: [PATCH 024/112] Advantage Air add zone temperature sensors (#51941) * Create AdvantageAirZoneTemp * Disable by default * Add test coverage * add state_class * Use entity class attributes * Match code style of PR #52498 --- .../components/advantage_air/sensor.py | 25 ++++++++++++++++-- tests/components/advantage_air/test_sensor.py | 26 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index edf079e1cba..e2bf90e73c3 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -2,7 +2,7 @@ import voluptuous as vol from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN @@ -25,9 +25,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append(AdvantageAirTimeTo(instance, ac_key, "On")) entities.append(AdvantageAirTimeTo(instance, ac_key, "Off")) for zone_key, zone in ac_device["zones"].items(): - # Only show damper sensors when zone is in temperature control + # Only show damper and temp sensors when zone is in temperature control if zone["type"] != 0: entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) + entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key)) # Only show wireless signal strength sensors when using wireless sensors if zone["rssi"] > 0: entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key)) @@ -144,3 +145,23 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): if self._zone["rssi"] >= 20: return "mdi:wifi-strength-1" return "mdi:wifi-strength-outline" + + +class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): + """Representation of Advantage Air Zone wireless signal sensor.""" + + _attr_unit_of_measurement = TEMP_CELSIUS + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_icon = "mdi:thermometer" + _attr_entity_registry_enabled_default = False + + def __init__(self, instance, ac_key, zone_key): + """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' + + @property + def state(self): + """Return the current value of the measured temperature.""" + return self._zone["measuredTemp"] diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 684b965d94f..997f11dea91 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,5 +1,6 @@ """Test the Advantage Air Sensor Platform.""" +from datetime import timedelta from json import loads from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN @@ -7,9 +8,12 @@ from homeassistant.components.advantage_air.sensor import ( ADVANTAGE_AIR_SERVICE_SET_TIME_TO, ADVANTAGE_AIR_SET_COUNTDOWN_VALUE, ) +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt +from tests.common import async_fire_time_changed from tests.components.advantage_air import ( TEST_SET_RESPONSE, TEST_SET_URL, @@ -125,3 +129,25 @@ async def test_sensor_platform(hass, aioclient_mock): entry = registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-signal" + + # Test First Zone Temp Sensor (disabled by default) + entity_id = "sensor.zone_open_with_sensor_temperature" + + assert not hass.states.get(entity_id) + + registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert int(state.state) == 25 + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-ac1-z01-temp" From 9d93f8b6d1f19e4ef57249003ed03f734bf6a5d6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 21 Jul 2021 00:11:58 +0000 Subject: [PATCH 025/112] [ci skip] Translation update --- .../components/airvisual/translations/ru.json | 6 ++-- .../airvisual/translations/sensor.ca.json | 20 +++++++++++ .../airvisual/translations/sensor.et.json | 20 +++++++++++ .../airvisual/translations/sensor.nl.json | 20 +++++++++++ .../airvisual/translations/sensor.ru.json | 20 +++++++++++ .../translations/sensor.zh-Hant.json | 20 +++++++++++ .../components/co2signal/translations/ca.json | 34 +++++++++++++++++++ .../components/co2signal/translations/et.json | 34 +++++++++++++++++++ .../components/co2signal/translations/nl.json | 34 +++++++++++++++++++ .../components/co2signal/translations/ru.json | 34 +++++++++++++++++++ .../co2signal/translations/zh-Hant.json | 34 +++++++++++++++++++ .../components/honeywell/translations/ca.json | 17 ++++++++++ .../components/honeywell/translations/et.json | 17 ++++++++++ .../components/honeywell/translations/nl.json | 17 ++++++++++ .../components/honeywell/translations/ru.json | 17 ++++++++++ .../honeywell/translations/zh-Hant.json | 17 ++++++++++ .../switcher_kis/translations/et.json | 13 +++++++ .../switcher_kis/translations/nl.json | 13 +++++++ 18 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/airvisual/translations/sensor.ca.json create mode 100644 homeassistant/components/airvisual/translations/sensor.et.json create mode 100644 homeassistant/components/airvisual/translations/sensor.nl.json create mode 100644 homeassistant/components/airvisual/translations/sensor.ru.json create mode 100644 homeassistant/components/airvisual/translations/sensor.zh-Hant.json create mode 100644 homeassistant/components/co2signal/translations/ca.json create mode 100644 homeassistant/components/co2signal/translations/et.json create mode 100644 homeassistant/components/co2signal/translations/nl.json create mode 100644 homeassistant/components/co2signal/translations/ru.json create mode 100644 homeassistant/components/co2signal/translations/zh-Hant.json create mode 100644 homeassistant/components/honeywell/translations/ca.json create mode 100644 homeassistant/components/honeywell/translations/et.json create mode 100644 homeassistant/components/honeywell/translations/nl.json create mode 100644 homeassistant/components/honeywell/translations/ru.json create mode 100644 homeassistant/components/honeywell/translations/zh-Hant.json create mode 100644 homeassistant/components/switcher_kis/translations/et.json create mode 100644 homeassistant/components/switcher_kis/translations/nl.json diff --git a/homeassistant/components/airvisual/translations/ru.json b/homeassistant/components/airvisual/translations/ru.json index 4f0073d3132..f774ec76aaf 100644 --- a/homeassistant/components/airvisual/translations/ru.json +++ b/homeassistant/components/airvisual/translations/ru.json @@ -17,7 +17,7 @@ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" }, - "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0448\u0438\u0440\u043e\u0442\u044b/\u0434\u043e\u043b\u0433\u043e\u0442\u044b.", + "description": "\u0414\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u043f\u043e \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u0430\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" }, "geography_by_name": { @@ -25,9 +25,9 @@ "api_key": "\u041a\u043b\u044e\u0447 API", "city": "\u0413\u043e\u0440\u043e\u0434", "country": "\u0421\u0442\u0440\u0430\u043d\u0430", - "state": "\u0448\u0442\u0430\u0442" + "state": "\u0428\u0442\u0430\u0442" }, - "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0433\u043e\u0440\u043e\u0434\u0430/\u0448\u0442\u0430\u0442\u0430/\u0441\u0442\u0440\u0430\u043d\u044b.", + "description": "\u0414\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0433\u043e\u0440\u043e\u0434\u0430/\u0448\u0442\u0430\u0442\u0430/\u0441\u0442\u0440\u0430\u043d\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" }, "node_pro": { diff --git a/homeassistant/components/airvisual/translations/sensor.ca.json b/homeassistant/components/airvisual/translations/sensor.ca.json new file mode 100644 index 00000000000..236dca64d4e --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ca.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f2xid de carboni", + "n2": "Di\u00f2xid de nitrogen", + "o3": "Oz\u00f3", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f2xid de sofre" + }, + "airvisual__pollutant_level": { + "good": "Bo", + "hazardous": "Perill\u00f3s", + "moderate": "Moderat", + "unhealthy": "Poc saludable", + "unhealthy_sensitive": "Poc saludable per a grups sensibles", + "very_unhealthy": "Molt poc saludable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.et.json b/homeassistant/components/airvisual/translations/sensor.et.json new file mode 100644 index 00000000000..14f3d82c11d --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.et.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Vingugaas", + "n2": "L\u00e4mmastikdioksiid", + "o3": "Osoon", + "p1": "PM10 osakesed", + "p2": "PM2.5 osakesed", + "s2": "V\u00e4\u00e4veldioksiid" + }, + "airvisual__pollutant_level": { + "good": "Hea", + "hazardous": "Ohtlik", + "moderate": "M\u00f5\u00f5dukas", + "unhealthy": "Ebatervislik", + "unhealthy_sensitive": "Ebatervislik riskir\u00fchmale", + "very_unhealthy": "V\u00e4ga ebatervislik" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.nl.json b/homeassistant/components/airvisual/translations/sensor.nl.json new file mode 100644 index 00000000000..72f07853e49 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.nl.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Koolmonoxide", + "n2": "Stikstofdioxide", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Zwaveldioxide" + }, + "airvisual__pollutant_level": { + "good": "Goed", + "hazardous": "Gevaarlijk", + "moderate": "Matig", + "unhealthy": "Ongezond", + "unhealthy_sensitive": "Ongezond voor gevoelige groepen", + "very_unhealthy": "Heel ongezond" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.ru.json b/homeassistant/components/airvisual/translations/sensor.ru.json new file mode 100644 index 00000000000..d75bcc4ee9e --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ru.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u0423\u0433\u0430\u0440\u043d\u044b\u0439 \u0433\u0430\u0437", + "n2": "\u0414\u0438\u043e\u043a\u0441\u0438\u0434 \u0430\u0437\u043e\u0442\u0430", + "o3": "\u041e\u0437\u043e\u043d", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u0414\u0438\u043e\u043a\u0441\u0438\u0434 \u0441\u0435\u0440\u044b" + }, + "airvisual__pollutant_level": { + "good": "\u0425\u043e\u0440\u043e\u0448\u043e", + "hazardous": "\u041e\u043f\u0430\u0441\u043d\u043e", + "moderate": "\u0421\u0440\u0435\u0434\u043d\u0435", + "unhealthy": "\u0412\u0440\u0435\u0434\u043d\u043e", + "unhealthy_sensitive": "\u0412\u0440\u0435\u0434\u043d\u043e \u0434\u043b\u044f \u0443\u044f\u0437\u0432\u0438\u043c\u044b\u0445 \u0433\u0440\u0443\u043f\u043f", + "very_unhealthy": "\u041e\u0447\u0435\u043d\u044c \u0432\u0440\u0435\u0434\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.zh-Hant.json b/homeassistant/components/airvisual/translations/sensor.zh-Hant.json new file mode 100644 index 00000000000..cedd3e33ae6 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.zh-Hant.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u4e00\u6c27\u5316\u78b3", + "n2": "\u4e8c\u6c27\u5316\u6c2e", + "o3": "\u81ed\u6c27", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u4e8c\u6c27\u5316\u786b" + }, + "airvisual__pollutant_level": { + "good": "\u826f\u597d", + "hazardous": "\u5371\u96aa", + "moderate": "\u4e2d\u7b49", + "unhealthy": "\u4e0d\u5065\u5eb7", + "unhealthy_sensitive": "\u5c0d\u654f\u611f\u65cf\u7fa4\u4e0d\u5065\u5eb7", + "very_unhealthy": "\u975e\u5e38\u4e0d\u5065\u5eb7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/ca.json b/homeassistant/components/co2signal/translations/ca.json new file mode 100644 index 00000000000..8a9539cfa97 --- /dev/null +++ b/homeassistant/components/co2signal/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "api_ratelimit": "S'ha superat la taxa l\u00edmit d'API", + "unknown": "Error inesperat" + }, + "error": { + "api_ratelimit": "S'ha superat la taxa l\u00edmit d'API", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + } + }, + "country": { + "data": { + "country_code": "Codi de pa\u00eds" + } + }, + "user": { + "data": { + "api_key": "Token d'acc\u00e9s", + "location": "Obt\u00e9 dades per" + }, + "description": "Visita https://co2signal.com/ per demanar un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/et.json b/homeassistant/components/co2signal/translations/et.json new file mode 100644 index 00000000000..a0d8f9db27f --- /dev/null +++ b/homeassistant/components/co2signal/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "api_ratelimit": "API p\u00e4rigute limiit on \u00fcletatud", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "api_ratelimit": "API p\u00e4rigute limiit on \u00fcletatud", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad" + } + }, + "country": { + "data": { + "country_code": "Riigi kood" + } + }, + "user": { + "data": { + "api_key": "Juurdep\u00e4\u00e4sut\u00f5end", + "location": "Hangi andmed" + }, + "description": "Loa taotlemiseks k\u00fclasta https://co2signal.com/." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/nl.json b/homeassistant/components/co2signal/translations/nl.json new file mode 100644 index 00000000000..54a7cd110cc --- /dev/null +++ b/homeassistant/components/co2signal/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "api_ratelimit": "API Ratelimit overschreden", + "unknown": "Onverwachte fout" + }, + "error": { + "api_ratelimit": "API Ratelimit overschreden", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + } + }, + "country": { + "data": { + "country_code": "Landcode" + } + }, + "user": { + "data": { + "api_key": "Toegangstoken", + "location": "Gegevens ophalen voor" + }, + "description": "Ga naar https://co2signal.com/ om een token aan te vragen." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/ru.json b/homeassistant/components/co2signal/translations/ru.json new file mode 100644 index 00000000000..c2be73b3c26 --- /dev/null +++ b/homeassistant/components/co2signal/translations/ru.json @@ -0,0 +1,34 @@ +{ + "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.", + "api_ratelimit": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d \u043f\u0440\u0435\u0434\u0435\u043b \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 API.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "api_ratelimit": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d \u043f\u0440\u0435\u0434\u0435\u043b \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 API.", + "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": { + "coordinates": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + } + }, + "country": { + "data": { + "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b" + } + }, + "user": { + "data": { + "api_key": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "location": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u043b\u044f" + }, + "description": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430 \u0441\u0430\u0439\u0442\u0435 https://co2signal.com/." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/zh-Hant.json b/homeassistant/components/co2signal/translations/zh-Hant.json new file mode 100644 index 00000000000..39cee0da0e5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "api_ratelimit": "\u8d85\u904e API \u5b58\u53d6\u9650\u5236", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "api_ratelimit": "\u8d85\u904e API \u5b58\u53d6\u9650\u5236", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6" + } + }, + "country": { + "data": { + "country_code": "\u570b\u78bc" + } + }, + "user": { + "data": { + "api_key": "\u5b58\u53d6\u6b0a\u6756", + "location": "\u53d6\u5f97\u8cc7\u6599\uff1a" + }, + "description": "\u700f\u89bd https://co2signal.com/ \u4ee5\u7372\u5f97\u6b0a\u6756\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/ca.json b/homeassistant/components/honeywell/translations/ca.json new file mode 100644 index 00000000000..34da1b89f10 --- /dev/null +++ b/homeassistant/components/honeywell/translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les credencials utilitzades per iniciar sessi\u00f3 a mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (EUA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/et.json b/homeassistant/components/honeywell/translations/et.json new file mode 100644 index 00000000000..264a1efeca5 --- /dev/null +++ b/homeassistant/components/honeywell/translations/et.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Tuvastamine nurjus" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta saidile mytotalconnectcomfort.com sisenemiseks kasutatav mandaat.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/nl.json b/homeassistant/components/honeywell/translations/nl.json new file mode 100644 index 00000000000..0abd80fa088 --- /dev/null +++ b/homeassistant/components/honeywell/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer de inloggegevens in die zijn gebruikt om in te loggen op mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/ru.json b/homeassistant/components/honeywell/translations/ru.json new file mode 100644 index 00000000000..1d775e6c2c7 --- /dev/null +++ b/homeassistant/components/honeywell/translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u0430 \u043d\u0430 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (\u0421\u0428\u0410)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/zh-Hant.json b/homeassistant/components/honeywell/translations/zh-Hant.json new file mode 100644 index 00000000000..906506d41a5 --- /dev/null +++ b/homeassistant/components/honeywell/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165\u767b\u5165 mytotalconnectcomfort.com \u4e4b\u6191\u8b49\u3002", + "title": "Honeywell Total Connect Comfort\uff08\u7f8e\u570b\uff09" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/et.json b/homeassistant/components/switcher_kis/translations/et.json new file mode 100644 index 00000000000..9e7bb472e0d --- /dev/null +++ b/homeassistant/components/switcher_kis/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 alustada seadistamist?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/nl.json b/homeassistant/components/switcher_kis/translations/nl.json new file mode 100644 index 00000000000..d11896014fd --- /dev/null +++ b/homeassistant/components/switcher_kis/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 beginnen met instellen?" + } + } + } +} \ No newline at end of file From 72bc7480818b733e633f0d234bc62b6a9dde1a63 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 21 Jul 2021 03:46:33 +0200 Subject: [PATCH 026/112] Avoid supplemental discovery of ignored upnp entry (#53250) --- homeassistant/components/upnp/config_flow.py | 6 ++- homeassistant/components/upnp/device.py | 4 ++ tests/components/upnp/test_config_flow.py | 56 ++++++++------------ 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index f52ce89660d..0679d9ffcb5 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -166,8 +166,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery = discovery_info_to_discovery(discovery_info) # Ensure not already configuring/configured. - discovery = await Device.async_supplement_discovery(self.hass, discovery) - unique_id = discovery[DISCOVERY_UNIQUE_ID] + unique_id = discovery[DISCOVERY_USN] await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME]} @@ -183,6 +182,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) 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] diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 9af7cf55c24..cf76aa41f8a 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -40,11 +40,15 @@ 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, } diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 6a911f8d4db..6e546be93f3 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch +from urllib.parse import urlparse from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp @@ -33,7 +34,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_flow_ssdp_discovery(hass: HomeAssistant): """Test config flow: discovered + configured through ssdp.""" udn = "uuid:device_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) ssdp_discoveries = [ { @@ -93,7 +94,7 @@ async def test_flow_ssdp_discovery(hass: HomeAssistant): async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): """Test config flow: incomplete discovery through ssdp.""" udn = "uuid:device_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) # Discovered via step ssdp. @@ -112,9 +113,9 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): - """Test config flow: discovery through ssdp, but ignored.""" + """Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry.""" udn = "uuid:device_random_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) # Existing entry. @@ -123,46 +124,31 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): data={ CONFIG_ENTRY_UDN: "uuid:device_random_2", CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, + CONFIG_ENTRY_HOSTNAME: urlparse(location).hostname, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) config_entry.add_to_hass(hass) - 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_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step ssdp, but ignored. - 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_ABORT - assert result["reason"] == "discovery_ignored" + # Discovered via step ssdp, but ignored. + 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_ABORT + assert result["reason"] == "discovery_ignored" async def test_flow_user(hass: HomeAssistant): """Test config flow: discovered + configured through user.""" udn = "uuid:device_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) ssdp_discoveries = [ { @@ -217,7 +203,7 @@ async def test_flow_import(hass: HomeAssistant): """Test config flow: discovered + configured through configuration.yaml.""" udn = "uuid:device_1" mock_device = MockDevice(udn) - location = "dummy" + location = "http://dummy" ssdp_discoveries = [ { ssdp.ATTR_SSDP_LOCATION: location, From 9d3bc0632f551e19c94cdc68f1f6503ca2e7e067 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 20 Jul 2021 19:47:37 -0600 Subject: [PATCH 027/112] Bump pylitterbot to 2021.7.2 (#53254) * Bump pylitterbot to 2021.7.1 * Bump pylitterbot dependency to 2021.7.2 which unpins Authlib and httpx dependencies --- 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 346bb5e0761..a22499fb062 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.3.1"], + "requirements": ["pylitterbot==2021.7.2"], "codeowners": ["@natekspencer"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 76f3f940306..fb87a9d615d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1548,7 +1548,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.3.1 +pylitterbot==2021.7.2 # homeassistant.components.loopenergy pyloopenergy==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9f4b83d37f..42197c6f659 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -874,7 +874,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.3.1 +pylitterbot==2021.7.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 From 56efee4603534855beb4495451506e4ddc53c069 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 20 Jul 2021 20:52:05 -0600 Subject: [PATCH 028/112] Ensure Ambient PWS is strictly typed (#53251) * Ensure Ambient PWS is strictly typed * Fix typing --- .strict-typing | 1 + homeassistant/components/ambient_station/__init__.py | 6 +++--- mypy.ini | 11 +++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index 981e3872eb5..40fb375ca16 100644 --- a/.strict-typing +++ b/.strict-typing @@ -13,6 +13,7 @@ homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.amazon_polly.* homeassistant.components.ambee.* +homeassistant.components.ambient_station.* homeassistant.components.ampio.* homeassistant.components.automation.* homeassistant.components.binary_sensor.* diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index d2f09e47f7b..12f534eb8e9 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -32,7 +32,7 @@ from homeassistant.const import ( SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -320,7 +320,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err - async def _async_disconnect_websocket(*_): + async def _async_disconnect_websocket(_: Event) -> None: await ambient.client.websocket.disconnect() config_entry.async_on_unload( @@ -378,7 +378,7 @@ class AmbientStation: async def _attempt_connect(self) -> None: """Attempt to connect to the socket (retrying later on fail).""" - async def connect(timestamp: int | None = None): + async def connect(timestamp: int | None = None) -> None: """Connect.""" await self.client.websocket.connect() diff --git a/mypy.ini b/mypy.ini index 942ef115135..b0d11d55262 100644 --- a/mypy.ini +++ b/mypy.ini @@ -154,6 +154,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ambient_station.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ampio.*] check_untyped_defs = true disallow_incomplete_defs = true From 8f61efe71485ad84bbebca10dc39ff84a6115b27 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 21 Jul 2021 04:53:56 +0200 Subject: [PATCH 029/112] Correct typing in edl21 and activate mypy. (#53188) --- homeassistant/components/edl21/sensor.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 16502632f4f..65ca1ee9050 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -126,7 +126,7 @@ class EDL21: def __init__(self, hass, config, async_add_entities) -> None: """Initialize an EDL21 object.""" - self._registered_obis = set() + self._registered_obis: set[()] = set() self._hass = hass self._async_add_entities = async_add_entities self._name = config[CONF_NAME] diff --git a/mypy.ini b/mypy.ini index b0d11d55262..cf85d2e60a9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1150,9 +1150,6 @@ ignore_errors = true [mypy-homeassistant.components.doorbird.*] ignore_errors = true -[mypy-homeassistant.components.edl21.*] -ignore_errors = true - [mypy-homeassistant.components.elkm1.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index a571047446c..7c97134397b 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -38,7 +38,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.dhcp.*", "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", - "homeassistant.components.edl21.*", "homeassistant.components.elkm1.*", "homeassistant.components.emonitor.*", "homeassistant.components.enphase_envoy.*", From 4d122fc3669174a646751a612a5056ac59fa5a7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:21:05 -1000 Subject: [PATCH 030/112] Update alexa lock to support locking, unlocking, jammed (#52841) --- homeassistant/components/alexa/capabilities.py | 7 +++++-- tests/components/alexa/test_capabilities.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 10b382c8dcf..db1fa990c54 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_NIGHT, ) import homeassistant.components.climate.const as climate +from homeassistant.components.lock import STATE_LOCKING, STATE_UNLOCKING import homeassistant.components.media_player.const as media_player from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, @@ -446,9 +447,11 @@ class AlexaLockController(AlexaCapability): if name != "lockState": raise UnsupportedProperty(name) - if self.entity.state == STATE_LOCKED: + # If its unlocking its still locked and not unlocked yet + if self.entity.state in (STATE_UNLOCKING, STATE_LOCKED): return "LOCKED" - if self.entity.state == STATE_UNLOCKED: + # If its locking its still unlocked and not locked yet + if self.entity.state in (STATE_LOCKING, STATE_UNLOCKED): return "UNLOCKED" return "JAMMED" diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 92951d4a0e7..dc93ed6d805 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.alexa import smart_home from homeassistant.components.alexa.errors import UnsupportedProperty from homeassistant.components.climate import const as climate +from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player.const import ( SUPPORT_PAUSE, SUPPORT_PLAY, @@ -227,17 +228,29 @@ async def test_report_lock_state(hass): """Test LockController implements lockState property.""" hass.states.async_set("lock.locked", STATE_LOCKED, {}) hass.states.async_set("lock.unlocked", STATE_UNLOCKED, {}) + hass.states.async_set("lock.unlocking", STATE_UNLOCKING, {}) + hass.states.async_set("lock.locking", STATE_LOCKING, {}) + hass.states.async_set("lock.jammed", STATE_JAMMED, {}) hass.states.async_set("lock.unknown", STATE_UNKNOWN, {}) properties = await reported_properties(hass, "lock.locked") properties.assert_equal("Alexa.LockController", "lockState", "LOCKED") + properties = await reported_properties(hass, "lock.unlocking") + properties.assert_equal("Alexa.LockController", "lockState", "LOCKED") + properties = await reported_properties(hass, "lock.unlocked") properties.assert_equal("Alexa.LockController", "lockState", "UNLOCKED") + properties = await reported_properties(hass, "lock.locking") + properties.assert_equal("Alexa.LockController", "lockState", "UNLOCKED") + properties = await reported_properties(hass, "lock.unknown") properties.assert_equal("Alexa.LockController", "lockState", "JAMMED") + properties = await reported_properties(hass, "lock.jammed") + properties.assert_equal("Alexa.LockController", "lockState", "JAMMED") + @pytest.mark.parametrize( "supported_color_modes", [["brightness"], ["hs"], ["color_temp"]] From 2a65c5f93cbd8f448953657ba9196df0652f3c3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:45:21 -1000 Subject: [PATCH 031/112] Recreate HomeKit accessories when calling the reset_accessory service (#53199) --- homeassistant/components/homekit/__init__.py | 119 +++++--- tests/components/homekit/test_homekit.py | 270 ++++++++++++++++++- 2 files changed, 345 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 5abc9adb9ca..5d9f2037610 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -120,6 +120,10 @@ PORT_CLEANUP_CHECK_INTERVAL_SECS = 1 MDNS_TARGET_IP = "224.0.0.251" +_HOMEKIT_CONFIG_UPDATE_TIME = ( + 5 # number of seconds to wait for homekit to see the c# change +) + def _has_all_unique_names_and_ports(bridges): """Validate that each homekit bridge configured has a unique name.""" @@ -351,7 +355,7 @@ def _async_register_events_and_services(hass: HomeAssistant): """Register events and services for HomeKit.""" hass.http.register_view(HomeKitPairingQRView) - def handle_homekit_reset_accessory(service): + async def async_handle_homekit_reset_accessory(service): """Handle start HomeKit service call.""" for entry_id in hass.data[DOMAIN]: if HOMEKIT not in hass.data[DOMAIN][entry_id]: @@ -365,12 +369,12 @@ def _async_register_events_and_services(hass: HomeAssistant): continue entity_ids = service.data.get("entity_id") - homekit.reset_accessories(entity_ids) + await homekit.async_reset_accessories(entity_ids) hass.services.async_register( DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY, - handle_homekit_reset_accessory, + async_handle_homekit_reset_accessory, schema=RESET_ACCESSORY_SERVICE_SCHEMA, ) @@ -486,36 +490,61 @@ class HomeKit: self.driver.persist() - def reset_accessories(self, entity_ids): + async def async_reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" if not self.bridge: - self.driver.config_changed() + await self.async_reset_accessories_in_accessory_mode(entity_ids) return + await self.async_reset_accessories_in_bridge_mode(entity_ids) - removed = [] + async def async_reset_accessories_in_accessory_mode(self, entity_ids): + """Reset accessories in accessory mode.""" + acc = self.driver.accessory + if acc.entity_id not in entity_ids: + return + acc.async_stop() + if not (state := self.hass.states.get(acc.entity_id)): + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) + return + if new_acc := self._async_create_single_accessory([state]): + self.driver.accessory = new_acc + await self.async_config_changed() + + async def async_reset_accessories_in_bridge_mode(self, entity_ids): + """Reset accessories in bridge mode.""" + new = [] for entity_id in entity_ids: aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) if aid not in self.bridge.accessories: continue - _LOGGER.info( "HomeKit Bridge %s will reset accessory with linked entity_id %s", self._name, entity_id, ) - acc = self.remove_bridge_accessory(aid) - removed.append(acc) + if state := self.hass.states.get(acc.entity_id): + new.append(state) + else: + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) - if not removed: + if not new: # No matched accessories, probably on another bridge return - self.driver.config_changed() + await self.async_config_changed() + await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) + for state in new: + self.add_bridge_accessory(state) + await self.async_config_changed() - for acc in removed: - self.bridge.add_accessory(acc) - self.driver.config_changed() + async def async_config_changed(self): + """Call config changed which writes out the new config to disk.""" + await self.hass.async_add_executor_job(self.driver.config_changed) def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" @@ -541,7 +570,7 @@ class HomeKit: ) aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) - conf = self._config.pop(state.entity_id, {}) + conf = self._config.get(state.entity_id, {}).copy() # 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 @@ -556,9 +585,9 @@ class HomeKit: def remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" - acc = None - if aid in self.bridge.accessories: - acc = self.bridge.accessories.pop(aid) + acc = self.bridge.accessories.pop(aid, None) + if acc: + acc.async_stop() return acc async def async_configure_accessories(self): @@ -665,33 +694,45 @@ class HomeKit: for device_id in devices_to_purge: dev_reg.async_remove_device(device_id) + @callback + def _async_create_single_accessory(self, entity_states): + """Create a single HomeKit accessory (accessory mode).""" + if not entity_states: + _LOGGER.error( + "HomeKit %s cannot startup: entity not available: %s", + self._name, + self._filter.config, + ) + return None + state = entity_states[0] + conf = self._config.get(state.entity_id, {}).copy() + acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + if acc is None: + _LOGGER.error( + "HomeKit %s cannot startup: entity not supported: %s", + self._name, + self._filter.config, + ) + return acc + + @callback + 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) + return self.bridge + async def _async_create_accessories(self): """Create the accessories.""" entity_states = await self.async_configure_accessories() if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: - if not entity_states: - _LOGGER.error( - "HomeKit %s cannot startup: entity not available: %s", - self._name, - self._filter.config, - ) - return False - state = entity_states[0] - conf = self._config.pop(state.entity_id, {}) - acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) - if acc is None: - _LOGGER.error( - "HomeKit %s cannot startup: entity not supported: %s", - self._name, - self._filter.config, - ) - return False + acc = self._async_create_single_accessory(entity_states) else: - self.bridge = HomeBridge(self.hass, self.driver, self._name) - for state in entity_states: - self.add_bridge_accessory(state) - acc = self.bridge + acc = self._async_create_bridge_accessory(entity_states) + if acc is None: + return False # No need to load/persist as we do it in setup self.driver.accessory = acc return True diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 235c3027c98..ba34830f381 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -434,10 +434,12 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): homekit.driver = "driver" homekit.bridge = _mock_pyhap_bridge() - homekit.bridge.accessories = {"light.demo": "acc"} + acc_mock = MagicMock() + homekit.bridge.accessories = {6: acc_mock} - acc = homekit.remove_bridge_accessory("light.demo") - assert acc == "acc" + acc = homekit.remove_bridge_accessory(6) + assert acc is acc_mock + assert acc_mock.async_stop.called assert len(homekit.bridge.accessories) == 0 @@ -627,12 +629,13 @@ async def test_homekit_stop(hass): async def test_homekit_reset_accessories(hass, mock_zeroconf): - """Test adding too many accessories to HomeKit.""" + """Test resetting HomeKit accessories.""" await async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) entity_id = "light.demo" + hass.states.async_set("light.demo", "on") homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( @@ -641,11 +644,15 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): "pyhap.accessory_driver.AccessoryDriver.config_changed" ) as hk_driver_config_changed, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) + acc_mock = MagicMock() + acc_mock.entity_id = entity_id aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - homekit.bridge.accessories = {aid: "acc"} + homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING await hass.services.async_call( @@ -661,6 +668,259 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): homekit.status = STATUS_READY +async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): + """Test resetting HomeKit accessories with an unsupported entity.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "not_supported.demo" + hass.states.async_set("not_supported.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 2 + assert not mock_add_accessory.called + assert len(homekit.bridge.accessories) == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_accessories_state_missing(hass, mock_zeroconf): + """Test resetting HomeKit accessories when the state goes missing.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + assert not mock_add_accessory.called + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_accessories_not_bridged(hass, mock_zeroconf): + """Test resetting HomeKit accessories when the state is not bridged.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: "light.not_bridged"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + assert not mock_add_accessory.called + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory(hass, mock_zeroconf): + """Test resetting HomeKit single accessory.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 1 + homekit.status = STATUS_READY + + +async def test_homekit_reset_single_accessory_unsupported(hass, mock_zeroconf): + """Test resetting HomeKit single accessory with an unsupported entity.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "not_supported.demo" + hass.states.async_set("not_supported.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory_state_missing(hass, mock_zeroconf): + """Test resetting HomeKit single accessory when the state goes missing.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory_no_match(hass, mock_zeroconf): + """Test resetting HomeKit single accessory when the entity id does not match.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: "light.no_match"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroconf): """Test adding too many accessories to HomeKit.""" entry = await async_init_integration(hass) From f20602e11d55b06cbb22bceebe895b13743583ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:46:39 -1000 Subject: [PATCH 032/112] Auto recreate HomeKit TVs when the sources are out of sync (#53208) --- .../components/homekit/accessories.py | 13 ++++++++++ .../components/homekit/type_remotes.py | 24 +++++++++++++++---- .../homekit/test_type_media_players.py | 2 +- tests/components/homekit/test_type_remote.py | 18 ++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 3c843955222..03d00c42a91 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -58,12 +58,14 @@ from .const import ( CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, DEVICE_CLASS_PM25, + DOMAIN, EVENT_HOMEKIT_CHANGED, HK_CHARGING, HK_NOT_CHARGABLE, HK_NOT_CHARGING, MANUFACTURER, SERV_BATTERY_SERVICE, + SERVICE_HOMEKIT_RESET_ACCESSORY, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -454,6 +456,17 @@ class HomeAccessory(Accessory): ) ) + @ha_callback + def async_reset(self): + """Reset and recreate an accessory.""" + self.hass.async_create_task( + self.hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: self.entity_id}, + ) + ) + @ha_callback def async_stop(self): """Cancel any subscriptions when the bridge is stopped.""" diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index e4f18a7c16f..718671dfd1d 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -87,6 +87,7 @@ class RemoteInputSelectAccessory(HomeAccessory): features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) self.source_key = source_key + self.source_list_key = source_list_key self.sources = [] self.support_select_source = False if features & required_feature: @@ -152,13 +153,26 @@ class RemoteInputSelectAccessory(HomeAccessory): index = self.sources.index(source_name) if self.char_input_source.value != index: self.char_input_source.set_value(index) - elif hk_state: - _LOGGER.warning( - "%s: Sources out of sync. Restart Home Assistant", + return + + possible_sources = new_state.attributes.get(self.source_list_key, []) + if source_name in possible_sources: + _LOGGER.debug( + "%s: Sources out of sync. Rebuilding Accessory", self.entity_id, ) - if self.char_input_source.value != 0: - self.char_input_source.set_value(0) + # Sources are out of sync, recreate the accessory + self.async_reset() + return + + _LOGGER.debug( + "%s: Source %s does not exist the source list: %s", + self.entity_id, + source_name, + possible_sources, + ) + if self.char_input_source.value != 0: + self.char_input_source.set_value(0) @TYPES.register("ActivityRemote") diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index b95903d3e3f..33cac7bcf8a 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -242,7 +242,7 @@ async def test_media_player_television(hass, hk_driver, events, caplog): hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 5"}) await hass.async_block_till_done() assert acc.char_input_source.value == 0 - assert caplog.records[-2].levelname == "WARNING" + assert caplog.records[-2].levelname == "DEBUG" # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index e69ebfb29fb..ee71d7f4e3c 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -3,8 +3,10 @@ from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, + DOMAIN as HOMEKIT_DOMAIN, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, KEY_ARROW_RIGHT, + SERVICE_HOMEKIT_RESET_ACCESSORY, ) from homeassistant.components.homekit.type_remotes import ActivityRemote from homeassistant.components.remote import ( @@ -146,3 +148,19 @@ async def test_activity_remote(hass, hk_driver, events, caplog): assert len(events) == 1 assert events[0].data[ATTR_KEY_NAME] == KEY_ARROW_RIGHT + + call_reset_accessory = async_mock_service( + hass, HOMEKIT_DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY + ) + # A wild source appears - The accessory should rebuild itself + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_ACTIVITY, + ATTR_CURRENT_ACTIVITY: "Amazon TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], + }, + ) + await hass.async_block_till_done() + assert call_reset_accessory[0].data[ATTR_ENTITY_ID] == entity_id From 0ce071e0a4c5fd2c09604038e55d708f51e83d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:47:13 -1000 Subject: [PATCH 033/112] Bump httpx to 0.18.2 (#53257) --- homeassistant/package_constraints.txt | 6 +----- requirements.txt | 2 +- script/gen_requirements_all.py | 4 ---- setup.py | 2 +- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f2448e7025b..62b7c5e95d5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -18,7 +18,7 @@ distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 home-assistant-frontend==20210707.0 -httpx==0.18.0 +httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 paho-mqtt==1.5.1 @@ -62,7 +62,3 @@ enum34==1000000000.0.0 typing==1000000000.0.0 uuid==1000000000.0.0 -# httpcore 0.13.4 breaks several integrations -# https://github.com/home-assistant/core/issues/51778 -httpcore==0.13.3 - diff --git a/requirements.txt b/requirements.txt index ad9c2717e94..dd445b8a7e9 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.0 +httpx==0.18.2 jinja2==3.0.1 PyJWT==1.7.1 cryptography==3.3.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dc0c5fa2c10..4fd96cb1b04 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -83,10 +83,6 @@ enum34==1000000000.0.0 typing==1000000000.0.0 uuid==1000000000.0.0 -# httpcore 0.13.4 breaks several integrations -# https://github.com/home-assistant/core/issues/51778 -httpcore==0.13.3 - """ IGNORE_PRE_COMMIT_HOOK_ID = ( diff --git a/setup.py b/setup.py index 758f4f3813d..db4e8a54d72 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.0", + "httpx==0.18.2", "jinja2==3.0.1", "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. From bfe3ef09800df2cbca8e4f3a249f983879b60808 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:48:15 -1000 Subject: [PATCH 034/112] Update august to support locking, unlocking, jammed (#52814) --- homeassistant/components/august/lock.py | 22 ++- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/august/test_lock.py | 129 ++++++++++++++++-- .../fixtures/august/get_activity.jammed.json | 34 +++++ .../fixtures/august/get_activity.locking.json | 34 +++++ .../august/get_activity.unlocking.json | 34 +++++ 8 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/august/get_activity.jammed.json create mode 100644 tests/fixtures/august/get_activity.locking.json create mode 100644 tests/fixtures/august/get_activity.unlocking.json diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index e74eded3557..5f4fe85bc71 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,6 +1,7 @@ """Support for August lock.""" import logging +from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType from yalexs.lock import LockStatus from yalexs.util import update_lock_detail_from_activity @@ -9,12 +10,15 @@ from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity +import homeassistant.util.dt as dt_util from .const import DATA_AUGUST, DOMAIN from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) +LOCK_JAMMED_ERR = 531 + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" @@ -44,9 +48,17 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): await self._call_lock_operation(self._data.async_unlock) async def _call_lock_operation(self, lock_operation): - activities = await lock_operation(self._device_id) - for lock_activity in activities: - update_lock_detail_from_activity(self._detail, lock_activity) + try: + activities = await lock_operation(self._device_id) + except ClientResponseError as err: + if err.status == LOCK_JAMMED_ERR: + self._detail.lock_status = LockStatus.JAMMED + self._detail.lock_status_datetime = dt_util.utcnow() + else: + raise + else: + for lock_activity in activities: + update_lock_detail_from_activity(self._detail, lock_activity) if self._update_lock_status_from_detail(): _LOGGER.debug( @@ -91,6 +103,10 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): else: self._attr_is_locked = self._lock_status is LockStatus.LOCKED + self._attr_is_jammed = self._lock_status is LockStatus.JAMMED + self._attr_is_locking = self._lock_status is LockStatus.LOCKING + self._attr_is_unlocking = self._lock_status is LockStatus.UNLOCKING + self._attr_extra_state_attributes = { ATTR_BATTERY_LEVEL: self._detail.battery_level } diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e966338f287..9f8b435b714 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,7 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["yalexs==1.1.11"], + "requirements": ["yalexs==1.1.12"], "codeowners": ["@bdraco"], "dhcp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index fb87a9d615d..a0b2fa1d554 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2401,7 +2401,7 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.3 # homeassistant.components.august -yalexs==1.1.11 +yalexs==1.1.12 # homeassistant.components.yeelight yeelight==0.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42197c6f659..36cfed2598e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ xknx==0.18.8 xmltodict==0.12.0 # homeassistant.components.august -yalexs==1.1.11 +yalexs==1.1.12 # homeassistant.components.yeelight yeelight==0.6.3 diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 5b3c163780f..a0b44d4fb79 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -2,9 +2,16 @@ import datetime from unittest.mock import Mock +from aiohttp import ClientResponseError +import pytest from yalexs.pubnub_async import AugustPubNub -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + STATE_JAMMED, + STATE_LOCKING, + STATE_UNLOCKING, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, @@ -59,6 +66,44 @@ async def test_lock_changed_by(hass): ) +async def test_state_locking(hass): + """Test creation of a lock with doorsense and bridge that is locking.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture(hass, "get_activity.locking.json") + await _create_august_with_devices(hass, [lock_one], activities=activities) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKING + + +async def test_state_unlocking(hass): + """Test creation of a lock with doorsense and bridge that is unlocking.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.unlocking.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + + +async def test_state_jammed(hass): + """Test creation of a lock with doorsense and bridge that is jammed.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture(hass, "get_activity.jammed.json") + await _create_august_with_devices(hass, [lock_one], activities=activities) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_JAMMED + + async def test_one_lock_operation(hass): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -109,6 +154,74 @@ async def test_one_lock_operation(hass): ) +async def test_lock_jammed(hass): + """Test lock gets jammed on unlock.""" + + def _unlock_return_activities_side_effect(access_token, device_id): + raise ClientResponseError(None, None, status=531) + + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + await _create_august_with_devices( + hass, + [lock_one], + api_call_side_effects={ + "unlock_return_activities": _unlock_return_activities_side_effect + }, + ) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) + + data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True + ) + await hass.async_block_till_done() + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_JAMMED + + +async def test_lock_throws_exception_on_unknown_status_code(hass): + """Test lock throws exception.""" + + def _unlock_return_activities_side_effect(access_token, device_id): + raise ClientResponseError(None, None, status=500) + + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + await _create_august_with_devices( + hass, + [lock_one], + api_call_side_effects={ + "unlock_return_activities": _unlock_return_activities_side_effect + }, + ) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) + + data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} + with pytest.raises(ClientResponseError): + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True + ) + await hass.async_block_till_done() + + async def test_one_lock_unknown_state(hass): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_lock_from_fixture( @@ -178,7 +291,7 @@ async def test_lock_update_via_pubnub(hass): await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING pubnub.message( pubnub, @@ -193,24 +306,24 @@ async def test_lock_update_via_pubnub(hass): await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING pubnub.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING # Ensure pubnub status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING pubnub.message( pubnub, @@ -224,12 +337,12 @@ async def test_lock_update_via_pubnub(hass): ) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/fixtures/august/get_activity.jammed.json b/tests/fixtures/august/get_activity.jammed.json new file mode 100644 index 00000000000..be5b9dfa4eb --- /dev/null +++ b/tests/fixtures/august/get_activity.jammed.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "jammed", + "dateTime" : 1582007218000, + "info" : { + "remote" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.locking.json b/tests/fixtures/august/get_activity.locking.json new file mode 100644 index 00000000000..c1f07e47312 --- /dev/null +++ b/tests/fixtures/august/get_activity.locking.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "locking", + "dateTime" : 1582007218000, + "info" : { + "remote" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.unlocking.json b/tests/fixtures/august/get_activity.unlocking.json new file mode 100644 index 00000000000..788a69164aa --- /dev/null +++ b/tests/fixtures/august/get_activity.unlocking.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "unlocking", + "dateTime" : 1582007218000, + "info" : { + "remote" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] From 5d85983b09f6eddcf69ab1eef6a547319590aa54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:49:05 -1000 Subject: [PATCH 035/112] Update google assistant locks to support locking, unlocking, jammed (#52820) --- .../components/google_assistant/trait.py | 7 +++- .../components/google_assistant/test_trait.py | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 0c547f18741..a710010bb8d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -24,6 +24,7 @@ from homeassistant.components import ( ) from homeassistant.components.climate import const as climate from homeassistant.components.humidifier import const as humidifier +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, @@ -1101,7 +1102,11 @@ class LockUnlockTrait(_Trait): def query_attributes(self): """Return LockUnlock query attributes.""" - return {"isLocked": self.state.state == STATE_LOCKED} + if self.state.state == STATE_JAMMED: + return {"isJammed": True} + + # If its unlocking its not yet unlocked so we consider is locked + return {"isLocked": self.state.state in (STATE_UNLOCKING, STATE_LOCKED)} async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index e2821d207d5..11677ed67d1 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1042,6 +1042,45 @@ async def test_lock_unlock_lock(hass): assert calls[0].data == {ATTR_ENTITY_ID: "lock.front_door"} +async def test_lock_unlock_unlocking(hass): + """Test LockUnlock trait locking support for lock domain.""" + assert helpers.get_google_type(lock.DOMAIN, None) is not None + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + + trt = trait.LockUnlockTrait( + hass, State("lock.front_door", lock.STATE_UNLOCKING), PIN_CONFIG + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {"isLocked": True} + + +async def test_lock_unlock_lock_jammed(hass): + """Test LockUnlock trait locking support for lock domain that jams.""" + assert helpers.get_google_type(lock.DOMAIN, None) is not None + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + + trt = trait.LockUnlockTrait( + hass, State("lock.front_door", lock.STATE_JAMMED), PIN_CONFIG + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {"isJammed": True} + + assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {"lock": True}) + + calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) + + await trt.execute(trait.COMMAND_LOCKUNLOCK, PIN_DATA, {"lock": True}, {}) + + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "lock.front_door"} + + async def test_lock_unlock_unlock(hass): """Test LockUnlock trait unlocking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None From ee242764a11b7dcd3628dbd951ed882e5d210596 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:50:21 -1000 Subject: [PATCH 036/112] Update template lock to support locking, unlocking, jammed (#52817) --- homeassistant/components/template/lock.py | 33 ++++++++-- tests/components/template/test_lock.py | 78 +++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index c4a3977a4db..55a568ed3c2 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -1,7 +1,13 @@ """Support for locks which integrates with other components.""" import voluptuous as vol -from homeassistant.components.lock import PLATFORM_SCHEMA, LockEntity +from homeassistant.components.lock import ( + PLATFORM_SCHEMA, + STATE_JAMMED, + STATE_LOCKING, + STATE_UNLOCKING, + LockEntity, +) from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, @@ -9,6 +15,8 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, STATE_LOCKED, STATE_ON, + STATE_UNKNOWN, + STATE_UNLOCKED, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError @@ -105,7 +113,22 @@ class TemplateLock(TemplateEntity, LockEntity): @property def is_locked(self): """Return true if lock is locked.""" - return self._state + return self._state in ("true", STATE_ON, STATE_LOCKED) + + @property + def is_jammed(self): + """Return true if lock is jammed.""" + return self._state == STATE_JAMMED + + @property + def is_unlocking(self): + """Return true if lock is unlocking.""" + return self._state == STATE_UNLOCKING + + @property + def is_locking(self): + """Return true if lock is locking.""" + return self._state == STATE_LOCKING @callback def _update_state(self, result): @@ -115,14 +138,14 @@ class TemplateLock(TemplateEntity, LockEntity): return if isinstance(result, bool): - self._state = result + self._state = STATE_LOCKED if result else STATE_UNLOCKED return if isinstance(result, str): - self._state = result.lower() in ("true", STATE_ON, STATE_LOCKED) + self._state = result.lower() return - self._state = False + self._state = STATE_UNKNOWN async def async_added_to_hass(self): """Register callbacks.""" diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index a00ca3b7e91..2cbdf23190d 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -325,6 +325,84 @@ async def test_unlock_action(hass, calls): assert len(calls) == 1 +async def test_unlocking(hass, calls): + """Test unlocking.""" + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + "lock": { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("input_select.test_state", lock.STATE_UNLOCKING) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_UNLOCKING + + +async def test_locking(hass, calls): + """Test unlocking.""" + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + "lock": { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("input_select.test_state", lock.STATE_LOCKING) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_LOCKING + + +async def test_jammed(hass, calls): + """Test jammed.""" + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + "lock": { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("input_select.test_state", lock.STATE_JAMMED) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_JAMMED + + async def test_available_template_with_entities(hass): """Test availability templates with values from other entities.""" From 564a5054865615c58a20409638bc65d4c6e5354b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:55:04 -1000 Subject: [PATCH 037/112] Update homekit controller lock to support locking, unlocking, jammed (#52821) --- .../components/homekit_controller/lock.py | 56 +++++++++++++++++-- .../homekit_controller/test_lock.py | 20 +++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 09c02ce0ff9..3b6fb41f3a8 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -2,18 +2,28 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from homeassistant.components.lock import LockEntity -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.components.lock import STATE_JAMMED, LockEntity +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + STATE_LOCKED, + STATE_UNKNOWN, + STATE_UNLOCKED, +) from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity -STATE_JAMMED = "jammed" - -CURRENT_STATE_MAP = {0: STATE_UNLOCKED, 1: STATE_LOCKED, 2: STATE_JAMMED, 3: None} +CURRENT_STATE_MAP = { + 0: STATE_UNLOCKED, + 1: STATE_LOCKED, + 2: STATE_JAMMED, + 3: STATE_UNKNOWN, +} TARGET_STATE_MAP = {STATE_UNLOCKED: 0, STATE_LOCKED: 1} +REVERSED_TARGET_STATE_MAP = {v: k for k, v in TARGET_STATE_MAP.items()} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Homekit lock.""" @@ -46,8 +56,44 @@ class HomeKitLock(HomeKitEntity, LockEntity): def is_locked(self): """Return true if device is locked.""" value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + if CURRENT_STATE_MAP[value] == STATE_UNKNOWN: + return None return CURRENT_STATE_MAP[value] == STATE_LOCKED + @property + def is_locking(self): + """Return true if device is locking.""" + current_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE + ) + target_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE + ) + return ( + CURRENT_STATE_MAP[current_value] == STATE_UNLOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_LOCKED + ) + + @property + def is_unlocking(self): + """Return true if device is unlocking.""" + current_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE + ) + target_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE + ) + return ( + CURRENT_STATE_MAP[current_value] == STATE_LOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_UNLOCKED + ) + + @property + def is_jammed(self): + """Return true if device is jammed.""" + value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + return CURRENT_STATE_MAP[value] == STATE_JAMMED + async def async_lock(self, **kwargs): """Lock the device.""" await self._set_lock_state(STATE_LOCKED) diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index 197b7b3c3b9..15e645bf181 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -57,3 +57,23 @@ async def test_switch_read_lock_state(hass, utcnow): helper.characteristics[LOCK_TARGET_STATE].value = 1 state = await helper.poll_and_get_state() assert state.state == "locked" + + helper.characteristics[LOCK_CURRENT_STATE].value = 2 + helper.characteristics[LOCK_TARGET_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == "jammed" + + helper.characteristics[LOCK_CURRENT_STATE].value = 3 + helper.characteristics[LOCK_TARGET_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == "unknown" + + helper.characteristics[LOCK_CURRENT_STATE].value = 0 + helper.characteristics[LOCK_TARGET_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == "locking" + + helper.characteristics[LOCK_CURRENT_STATE].value = 1 + helper.characteristics[LOCK_TARGET_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.state == "unlocking" From fe89603ee78e050e342c3151eed629b3e08603e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jul 2021 18:55:19 -1000 Subject: [PATCH 038/112] Update homekit lock to support locking, unlocking, jammed (#52819) --- .../components/homekit/type_locks.py | 92 ++++++++++++------- tests/components/homekit/test_type_locks.py | 22 ++++- 2 files changed, 81 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 17e2eee46e8..3a10a0a2f5a 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -3,7 +3,14 @@ import logging from pyhap.const import CATEGORY_DOOR_LOCK -from homeassistant.components.lock import DOMAIN, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.components.lock import ( + DOMAIN, + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import callback @@ -12,16 +19,37 @@ from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = { +HASS_TO_HOMEKIT_CURRENT = { STATE_UNLOCKED: 0, + STATE_UNLOCKING: 1, + STATE_LOCKING: 0, STATE_LOCKED: 1, - # Value 2 is Jammed which hass doesn't have a state for + STATE_JAMMED: 2, STATE_UNKNOWN: 3, } -HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +HASS_TO_HOMEKIT_TARGET = { + STATE_UNLOCKED: 0, + STATE_UNLOCKING: 0, + STATE_LOCKING: 1, + STATE_LOCKED: 1, +} -STATE_TO_SERVICE = {STATE_LOCKED: "lock", STATE_UNLOCKED: "unlock"} +VALID_TARGET_STATES = {STATE_LOCKING, STATE_UNLOCKING, STATE_LOCKED, STATE_UNLOCKED} + +HOMEKIT_TO_HASS = { + 0: STATE_UNLOCKED, + 1: STATE_LOCKED, + 2: STATE_JAMMED, + 3: STATE_UNKNOWN, +} + +STATE_TO_SERVICE = { + STATE_LOCKING: "unlock", + STATE_LOCKED: "lock", + STATE_UNLOCKING: "lock", + STATE_UNLOCKED: "unlock", +} @TYPES.register("Lock") @@ -39,11 +67,11 @@ class Lock(HomeAccessory): serv_lock_mechanism = self.add_preload_service(SERV_LOCK) self.char_current_state = serv_lock_mechanism.configure_char( - CHAR_LOCK_CURRENT_STATE, value=HASS_TO_HOMEKIT[STATE_UNKNOWN] + CHAR_LOCK_CURRENT_STATE, value=HASS_TO_HOMEKIT_CURRENT[STATE_UNKNOWN] ) self.char_target_state = serv_lock_mechanism.configure_char( CHAR_LOCK_TARGET_STATE, - value=HASS_TO_HOMEKIT[STATE_LOCKED], + value=HASS_TO_HOMEKIT_CURRENT[STATE_LOCKED], setter_callback=self.set_state, ) self.async_update_state(state) @@ -52,12 +80,9 @@ class Lock(HomeAccessory): """Set lock state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) - hass_value = HOMEKIT_TO_HASS.get(value) + hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] - if self.char_current_state.value != value: - self.char_current_state.set_value(value) - params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code @@ -67,25 +92,28 @@ class Lock(HomeAccessory): def async_update_state(self, new_state): """Update lock after state changed.""" hass_state = new_state.state - if hass_state in HASS_TO_HOMEKIT: - current_lock_state = HASS_TO_HOMEKIT[hass_state] - _LOGGER.debug( - "%s: Updated current state to %s (%d)", - self.entity_id, - hass_state, - current_lock_state, - ) - # LockTargetState only supports locked and unlocked - # Must set lock target state before current state - # or there will be no notification - if ( - hass_state in (STATE_LOCKED, STATE_UNLOCKED) - and self.char_target_state.value != current_lock_state - ): - self.char_target_state.set_value(current_lock_state) + current_lock_state = HASS_TO_HOMEKIT_CURRENT.get( + hass_state, HASS_TO_HOMEKIT_CURRENT[STATE_UNKNOWN] + ) + target_lock_state = HASS_TO_HOMEKIT_TARGET.get(hass_state) + _LOGGER.debug( + "%s: Updated current state to %s (current=%d) (target=%s)", + self.entity_id, + hass_state, + current_lock_state, + target_lock_state, + ) + # 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 + ): + 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) + # 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) diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index b2bb9b4736e..e47f4dfac71 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -3,7 +3,12 @@ import pytest from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_locks import Lock -from homeassistant.components.lock import DOMAIN +from homeassistant.components.lock import ( + DOMAIN, + STATE_JAMMED, + STATE_LOCKING, + STATE_UNLOCKING, +) from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -37,11 +42,26 @@ async def test_lock_unlock(hass, hk_driver, events): assert acc.char_current_state.value == 1 assert acc.char_target_state.value == 1 + hass.states.async_set(entity_id, STATE_LOCKING) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 1 + hass.states.async_set(entity_id, STATE_UNLOCKED) await hass.async_block_till_done() assert acc.char_current_state.value == 0 assert acc.char_target_state.value == 0 + hass.states.async_set(entity_id, STATE_UNLOCKING) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_JAMMED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 2 + assert acc.char_target_state.value == 0 + hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() assert acc.char_current_state.value == 3 From 8a7cb389ed3afdaa0ed497abd44466420c209267 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 21 Jul 2021 07:07:15 +0200 Subject: [PATCH 039/112] Drop support for fan speeds and support reverse (#53105) --- .../components/google_assistant/trait.py | 102 +++++++++-------- .../components/google_assistant/test_trait.py | 105 ++++++++---------- 2 files changed, 103 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index a710010bb8d..36222902296 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -142,6 +142,7 @@ COMMAND_MEDIA_SEEK_RELATIVE = f"{PREFIX_COMMANDS}mediaSeekRelative" COMMAND_MEDIA_SEEK_TO_POSITION = f"{PREFIX_COMMANDS}mediaSeekToPosition" COMMAND_MEDIA_SHUFFLE = f"{PREFIX_COMMANDS}mediaShuffle" 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" @@ -1258,14 +1259,7 @@ class FanSpeedTrait(_Trait): """ name = TRAIT_FANSPEED - commands = [COMMAND_FANSPEED] - - speed_synonyms = { - fan.SPEED_OFF: ["stop", "off"], - fan.SPEED_LOW: ["slow", "low", "slowest", "lowest"], - fan.SPEED_MEDIUM: ["medium", "mid", "middle"], - fan.SPEED_HIGH: ["high", "max", "fast", "highest", "fastest", "maximum"], - } + commands = [COMMAND_FANSPEED, COMMAND_REVERSE] @staticmethod def supported(domain, features, device_class, _): @@ -1280,23 +1274,21 @@ class FanSpeedTrait(_Trait): """Return speed point and modes attributes for a sync request.""" domain = self.state.domain speeds = [] - reversible = False + result = {} if domain == fan.DOMAIN: - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) - for mode in modes: - speed = { - "speed_name": mode, - "speed_values": [ - {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} - ], - } - speeds.append(speed) reversible = bool( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & fan.SUPPORT_DIRECTION ) + + result.update( + { + "reversible": reversible, + "supportsFanSpeedPercent": True, + } + ) + elif domain == climate.DOMAIN: modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) for mode in modes: @@ -1306,32 +1298,32 @@ class FanSpeedTrait(_Trait): } speeds.append(speed) - return { - "availableFanSpeeds": {"speeds": speeds, "ordered": True}, - "reversible": reversible, - "supportsFanSpeedPercent": True, - } + result.update( + { + "reversible": False, + "availableFanSpeeds": {"speeds": speeds, "ordered": True}, + } + ) + + return result def query_attributes(self): """Return speed point and modes query attributes.""" + attrs = self.state.attributes domain = self.state.domain response = {} if domain == climate.DOMAIN: - speed = attrs.get(climate.ATTR_FAN_MODE) - if speed is not None: - response["currentFanSpeedSetting"] = speed + speed = attrs.get(climate.ATTR_FAN_MODE) or "off" + response["currentFanSpeedSetting"] = speed + if domain == fan.DOMAIN: - speed = attrs.get(fan.ATTR_SPEED) percent = attrs.get(fan.ATTR_PERCENTAGE) or 0 - if speed is not None: - response["on"] = speed != fan.SPEED_OFF - response["currentFanSpeedSetting"] = speed - if percent is not None: - response["currentFanSpeedPercent"] = percent + response["currentFanSpeedPercent"] = percent + return response - async def execute(self, command, data, params, challenge): + async def execute_fanspeed(self, data, params): """Execute an SetFanSpeed command.""" domain = self.state.domain if domain == climate.DOMAIN: @@ -1345,25 +1337,43 @@ class FanSpeedTrait(_Trait): blocking=True, context=data.context, ) - if domain == fan.DOMAIN: - service_params = { - ATTR_ENTITY_ID: self.state.entity_id, - } - if "fanSpeedPercent" in params: - service = fan.SERVICE_SET_PERCENTAGE - service_params[fan.ATTR_PERCENTAGE] = params["fanSpeedPercent"] - else: - service = fan.SERVICE_SET_SPEED - service_params[fan.ATTR_SPEED] = params["fanSpeed"] + if domain == fan.DOMAIN: await self.hass.services.async_call( fan.DOMAIN, - service, - service_params, + fan.SERVICE_SET_PERCENTAGE, + { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_PERCENTAGE: params["fanSpeedPercent"], + }, blocking=True, context=data.context, ) + async def execute_reverse(self, data, params): + """Execute a Reverse command.""" + domain = self.state.domain + if domain == fan.DOMAIN: + if self.state.attributes.get(fan.ATTR_DIRECTION) == fan.DIRECTION_FORWARD: + direction = fan.DIRECTION_REVERSE + else: + direction = fan.DIRECTION_FORWARD + + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_DIRECTION: direction}, + blocking=True, + context=data.context, + ) + + async def execute(self, command, data, params, challenge): + """Execute a smart home command.""" + if command == COMMAND_FANSPEED: + await self.execute_fanspeed(data, params) + elif command == COMMAND_REVERSE: + await self.execute_reverse(data, params) + @register_trait class ModesTrait(_Trait): diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 11677ed67d1..c57d894c36d 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1461,13 +1461,6 @@ async def test_fan_speed(hass): "fan.living_room_fan", fan.SPEED_HIGH, attributes={ - "speed_list": [ - fan.SPEED_OFF, - fan.SPEED_LOW, - fan.SPEED_MEDIUM, - fan.SPEED_HIGH, - ], - "speed": "low", "percentage": 33, "percentage_step": 1.0, }, @@ -1476,64 +1469,14 @@ async def test_fan_speed(hass): ) assert trt.sync_attributes() == { - "availableFanSpeeds": { - "ordered": True, - "speeds": [ - { - "speed_name": "off", - "speed_values": [{"speed_synonym": ["stop", "off"], "lang": "en"}], - }, - { - "speed_name": "low", - "speed_values": [ - { - "speed_synonym": ["slow", "low", "slowest", "lowest"], - "lang": "en", - } - ], - }, - { - "speed_name": "medium", - "speed_values": [ - {"speed_synonym": ["medium", "mid", "middle"], "lang": "en"} - ], - }, - { - "speed_name": "high", - "speed_values": [ - { - "speed_synonym": [ - "high", - "max", - "fast", - "highest", - "fastest", - "maximum", - ], - "lang": "en", - } - ], - }, - ], - }, "reversible": False, "supportsFanSpeedPercent": True, } assert trt.query_attributes() == { - "currentFanSpeedSetting": "low", - "on": True, "currentFanSpeedPercent": 33, } - assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeed": "medium"}) - - calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) - await trt.execute(trait.COMMAND_FANSPEED, BASIC_DATA, {"fanSpeed": "medium"}, {}) - - assert len(calls) == 1 - assert calls[0].data == {"entity_id": "fan.living_room_fan", "speed": "medium"} - assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeedPercent": 10}) calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_PERCENTAGE) @@ -1543,6 +1486,53 @@ async def test_fan_speed(hass): assert calls[0].data == {"entity_id": "fan.living_room_fan", "percentage": 10} +@pytest.mark.parametrize( + "direction_state,direction_call", + [ + (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE), + (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD), + (None, fan.DIRECTION_FORWARD), + ], +) +async def test_fan_reverse(hass, direction_state, direction_call): + """Test FanSpeed trait speed control support for fan domain.""" + + calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_DIRECTION) + + trt = trait.FanSpeedTrait( + hass, + State( + "fan.living_room_fan", + fan.SPEED_HIGH, + attributes={ + "percentage": 33, + "percentage_step": 1.0, + "direction": direction_state, + "supported_features": fan.SUPPORT_DIRECTION, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "reversible": True, + "supportsFanSpeedPercent": True, + } + + assert trt.query_attributes() == { + "currentFanSpeedPercent": 33, + } + + assert trt.can_execute(trait.COMMAND_REVERSE, params={}) + await trt.execute(trait.COMMAND_REVERSE, BASIC_DATA, {}, {}) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "fan.living_room_fan", + "direction": direction_call, + } + + async def test_climate_fan_speed(hass): """Test FanSpeed trait speed control support for climate domain.""" assert helpers.get_google_type(climate.DOMAIN, None) is not None @@ -1586,7 +1576,6 @@ async def test_climate_fan_speed(hass): ], }, "reversible": False, - "supportsFanSpeedPercent": True, } assert trt.query_attributes() == { From 90765132cca33bc247cad098dee4d1eca3447560 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 21 Jul 2021 01:08:08 -0400 Subject: [PATCH 040/112] Make additional input for zwave_js device triggers optional (#53134) --- .../components/zwave_js/device_trigger.py | 7 +- .../zwave_js/test_device_trigger.py | 187 +++++++++++++++++- 2 files changed, 182 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 6734afd10e2..6d1b611d14f 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -92,7 +92,7 @@ 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.Required(ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(ATTR_VALUE): vol.Coerce(int), + vol.Optional(ATTR_VALUE): vol.Coerce(int), vol.Required(CONF_SUBTYPE): cv.string, } ) @@ -286,7 +286,8 @@ async def async_attach_trigger( copy_available_params( config, event_data, [ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_ENDPOINT] ) - event_data[ATTR_VALUE_RAW] = config[ATTR_VALUE] + if ATTR_VALUE in config: + event_data[ATTR_VALUE_RAW] = config[ATTR_VALUE] else: raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") @@ -366,6 +367,6 @@ async def async_get_trigger_capabilities( vol.Range(min=value.metadata.min, max=value.metadata.max), ) - return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} + return {"extra_fields": vol.Schema({vol.Optional(ATTR_VALUE): value_schema})} return {} diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 2c4d8ce2b33..86e053a5882 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -68,6 +68,7 @@ async def test_if_notification_notification_fires( automation.DOMAIN, { automation.DOMAIN: [ + # event, type, label { "trigger": { "platform": "device", @@ -91,6 +92,27 @@ async def test_if_notification_notification_fires( }, }, }, + # no type, event, label + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.notification", + "command_class": CommandClass.NOTIFICATION.value, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.notification.notification2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, ] }, ) @@ -114,12 +136,17 @@ async def test_if_notification_notification_fires( ) node.receive_event(event) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0].data[ "some" ] == "event.notification.notification - device - zwave_js_notification - {}".format( CommandClass.NOTIFICATION ) + assert calls[1].data[ + "some" + ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( + CommandClass.NOTIFICATION + ) async def test_get_trigger_capabilities_notification_notification( @@ -166,6 +193,7 @@ async def test_if_entry_control_notification_fires( automation.DOMAIN, { automation.DOMAIN: [ + # event_type and data_type { "trigger": { "platform": "device", @@ -188,6 +216,27 @@ async def test_if_entry_control_notification_fires( }, }, }, + # no event_type and data_type + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.entry_control", + "command_class": CommandClass.ENTRY_CONTROL.value, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.notification.notification2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, ] }, ) @@ -205,12 +254,17 @@ async def test_if_entry_control_notification_fires( ) node.receive_event(event) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0].data[ "some" ] == "event.notification.notification - device - zwave_js_notification - {}".format( CommandClass.ENTRY_CONTROL ) + assert calls[1].data[ + "some" + ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( + CommandClass.ENTRY_CONTROL + ) async def test_get_trigger_capabilities_entry_control_notification( @@ -285,6 +339,7 @@ async def test_if_node_status_change_fires( automation.DOMAIN, { automation.DOMAIN: [ + # from { "trigger": { "platform": "device", @@ -305,6 +360,26 @@ async def test_if_node_status_change_fires( }, }, }, + # no from or to + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": entity_id, + "type": "state.node_status", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "state.node_status2 - " + "{{ trigger.platform}} - " + "{{ trigger.from_state.state }}" + ) + }, + }, + }, ] }, ) @@ -315,8 +390,9 @@ async def test_if_node_status_change_fires( ) node.receive_event(event) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0].data["some"] == "state.node_status - device - alive" + assert calls[1].data["some"] == "state.node_status2 - device - alive" async def test_get_trigger_capabilities_node_status( @@ -408,6 +484,7 @@ async def test_if_basic_value_notification_fires( automation.DOMAIN, { automation.DOMAIN: [ + # value { "trigger": { "platform": "device", @@ -433,6 +510,31 @@ async def test_if_basic_value_notification_fires( }, }, }, + # no value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.basic", + "device_id": device.id, + "command_class": CommandClass.BASIC.value, + "property": "event", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.basic2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, ] }, ) @@ -465,12 +567,17 @@ async def test_if_basic_value_notification_fires( ) node.receive_event(event) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0].data[ "some" ] == "event.value_notification.basic - device - zwave_js_value_notification - {}".format( CommandClass.BASIC ) + assert calls[1].data[ + "some" + ] == "event.value_notification.basic2 - device - zwave_js_value_notification - {}".format( + CommandClass.BASIC + ) async def test_get_trigger_capabilities_basic_value_notification( @@ -500,7 +607,7 @@ async def test_get_trigger_capabilities_basic_value_notification( ) == [ { "name": "value", - "required": True, + "optional": True, "type": "integer", "valueMin": 0, "valueMax": 255, @@ -542,6 +649,7 @@ async def test_if_central_scene_value_notification_fires( automation.DOMAIN, { automation.DOMAIN: [ + # value { "trigger": { "platform": "device", @@ -567,6 +675,31 @@ async def test_if_central_scene_value_notification_fires( }, }, }, + # no value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.value_notification.central_scene", + "command_class": CommandClass.CENTRAL_SCENE.value, + "property": "scene", + "property_key": "001", + "endpoint": 0, + "subtype": "Endpoint 0 Scene 001", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.central_scene2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, ] }, ) @@ -606,12 +739,17 @@ async def test_if_central_scene_value_notification_fires( ) node.receive_event(event) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0].data[ "some" ] == "event.value_notification.central_scene - device - zwave_js_value_notification - {}".format( CommandClass.CENTRAL_SCENE ) + assert calls[1].data[ + "some" + ] == "event.value_notification.central_scene2 - device - zwave_js_value_notification - {}".format( + CommandClass.CENTRAL_SCENE + ) async def test_get_trigger_capabilities_central_scene_value_notification( @@ -641,7 +779,7 @@ async def test_get_trigger_capabilities_central_scene_value_notification( ) == [ { "name": "value", - "required": True, + "optional": True, "type": "select", "options": [(0, "KeyPressed"), (1, "KeyReleased"), (2, "KeyHeldDown")], }, @@ -682,6 +820,7 @@ async def test_if_scene_activation_value_notification_fires( automation.DOMAIN, { automation.DOMAIN: [ + # value { "trigger": { "platform": "device", @@ -707,6 +846,31 @@ async def test_if_scene_activation_value_notification_fires( }, }, }, + # No value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.value_notification.scene_activation", + "command_class": CommandClass.SCENE_ACTIVATION.value, + "property": "sceneId", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.scene_activation2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, ] }, ) @@ -739,12 +903,17 @@ async def test_if_scene_activation_value_notification_fires( ) node.receive_event(event) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0].data[ "some" ] == "event.value_notification.scene_activation - device - zwave_js_value_notification - {}".format( CommandClass.SCENE_ACTIVATION ) + assert calls[1].data[ + "some" + ] == "event.value_notification.scene_activation2 - device - zwave_js_value_notification - {}".format( + CommandClass.SCENE_ACTIVATION + ) async def test_get_trigger_capabilities_scene_activation_value_notification( @@ -774,7 +943,7 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( ) == [ { "name": "value", - "required": True, + "optional": True, "type": "integer", "valueMin": 1, "valueMax": 255, From 8a72e8df79883897c42ac9a1a6a743b64be654f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 21 Jul 2021 07:41:08 +0200 Subject: [PATCH 041/112] Convert Mill consumption attributes to sensors (#52311) --- .coveragerc | 1 + homeassistant/components/mill/__init__.py | 21 +++++- homeassistant/components/mill/climate.py | 21 +----- homeassistant/components/mill/const.py | 4 +- homeassistant/components/mill/sensor.py | 85 +++++++++++++++++++++++ 5 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/mill/sensor.py diff --git a/.coveragerc b/.coveragerc index 3b6c140c0f1..84577e99197 100644 --- a/.coveragerc +++ b/.coveragerc @@ -622,6 +622,7 @@ omit = homeassistant/components/mill/__init__.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py + homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minecraft_server/const.py diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 115bb5eb33c..75422cd26e1 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -1,10 +1,29 @@ """The mill component.""" +from mill import Mill -PLATFORMS = ["climate"] +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS = ["climate", "sensor"] async def async_setup_entry(hass, entry): """Set up the Mill heater.""" + mill_data_connection = Mill( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + if not await mill_data_connection.connect(): + raise ConfigEntryNotReady + + await mill_data_connection.find_all_heaters() + + hass.data[DOMAIN] = mill_data_connection + 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 2d591c67668..16c78329b0b 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -1,5 +1,4 @@ """Support for mill wifi-enabled home heaters.""" -from mill import Mill import voluptuous as vol from homeassistant.components.climate import ClimateEntity @@ -12,15 +11,8 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_PASSWORD, - CONF_USERNAME, - TEMP_CELSIUS, -) -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( ATTR_AWAY_TEMP, @@ -48,15 +40,8 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema( async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill climate.""" - mill_data_connection = Mill( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - websession=async_get_clientsession(hass), - ) - if not await mill_data_connection.connect(): - raise ConfigEntryNotReady - await mill_data_connection.find_all_heaters() + mill_data_connection = hass.data[DOMAIN] dev = [] for heater in mill_data_connection.heaters.values(): @@ -109,8 +94,6 @@ class MillHeater(ClimateEntity): "heating": self._heater.is_heating, "controlled_by_tibber": self._heater.tibber_control, "heater_generation": 1 if self._heater.is_gen1 else 2, - "consumption_today": self._heater.day_consumption, - "consumption_total": self._heater.year_consumption, } if self._heater.room: res["room"] = self._heater.room.name diff --git a/homeassistant/components/mill/const.py b/homeassistant/components/mill/const.py index b0ba7065e0a..61171420e44 100644 --- a/homeassistant/components/mill/const.py +++ b/homeassistant/components/mill/const.py @@ -4,8 +4,10 @@ ATTR_AWAY_TEMP = "away_temp" ATTR_COMFORT_TEMP = "comfort_temp" ATTR_ROOM_NAME = "room_name" ATTR_SLEEP_TEMP = "sleep_temp" +CONSUMPTION_TODAY = "consumption_today" +CONSUMPTION_YEAR = "consumption_year" +DOMAIN = "mill" MANUFACTURER = "Mill" MAX_TEMP = 35 MIN_TEMP = 5 -DOMAIN = "mill" SERVICE_SET_ROOM_TEMP = "set_room_temperature" diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py new file mode 100644 index 00000000000..5b0cf7efd73 --- /dev/null +++ b/homeassistant/components/mill/sensor.py @@ -0,0 +1,85 @@ +"""Support for mill wifi-enabled home heaters.""" + +from homeassistant.components.sensor import ( + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) +from homeassistant.const import ENERGY_KILO_WATT_HOUR, STATE_UNKNOWN +from homeassistant.util import dt as dt_util + +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] + + 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) + + +class MillHeaterEnergySensor(SensorEntity): + """Representation of a Mill Sensor device.""" + + 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_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, + "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.""" + heater = await self._conn.update_device(self._id) + self._attr_available = heater.available + + if self._sensor_type == CONSUMPTION_TODAY: + _state = heater.day_consumption + elif self._sensor_type == CONSUMPTION_YEAR: + _state = heater.year_consumption + else: + _state = None + if _state is None: + self._attr_state = _state + return + + if self.state not in [STATE_UNKNOWN, 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_state = _state From 2e2b340b1ea83f18085235ac31ee47782620ffbd Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 21 Jul 2021 07:48:02 +0200 Subject: [PATCH 042/112] Set modbus entity to non-available unless scan_interval=0 (#53155) --- homeassistant/components/modbus/base_platform.py | 1 + tests/components/modbus/test_binary_sensor.py | 2 ++ tests/components/modbus/test_climate.py | 2 ++ tests/components/modbus/test_cover.py | 3 +++ tests/components/modbus/test_fan.py | 4 ++++ tests/components/modbus/test_light.py | 4 ++++ tests/components/modbus/test_sensor.py | 2 ++ tests/components/modbus/test_switch.py | 3 +++ 8 files changed, 21 insertions(+) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 39ec283519a..0b612c3ecf5 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -68,6 +68,7 @@ class BasePlatform(Entity): self._value = None self._available = True self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) + self._available = self._scan_interval == 0 self._call_active = False @abstractmethod diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index e9c178ff025..e77fd380a22 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_DEVICE_CLASS, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SLAVE, STATE_OFF, STATE_ON, @@ -144,6 +145,7 @@ async def test_service_binary_sensor_update(hass, mock_pymodbus): { CONF_NAME: SENSOR_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 b872f4fe302..97d2c32ba69 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -104,6 +104,7 @@ async def test_service_climate_update(hass, mock_pymodbus): CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, } ] } @@ -176,6 +177,7 @@ test_value.attributes = {ATTR_TEMPERATURE: 37} CONF_NAME: CLIMATE_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 37274603bee..8d7e7e39cf8 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -211,6 +211,7 @@ async def test_service_cover_update(hass, mock_pymodbus): CONF_STATE_CLOSING: 3, CONF_STATUS_REGISTER: 1234, CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -232,11 +233,13 @@ async def test_service_cover_move(hass, mock_pymodbus): 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, }, ] } diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 4eeb094130b..13714d6bd0e 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SLAVE, CONF_TYPE, STATE_OFF, @@ -195,6 +196,7 @@ async def test_all_fan(hass, call_type, regs, verify, expected): { CONF_NAME: FAN_NAME, CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -219,11 +221,13 @@ async def test_fan_service_turn(hass, caplog, mock_pymodbus): CONF_NAME: FAN_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, }, { CONF_NAME: f"{FAN_NAME}2", CONF_ADDRESS: 17, 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 e962b69a2a6..c7b9b820934 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_LIGHTS, CONF_NAME, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SLAVE, CONF_TYPE, STATE_OFF, @@ -195,6 +196,7 @@ async def test_all_light(hass, call_type, regs, verify, expected): { CONF_NAME: LIGHT_NAME, CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -219,11 +221,13 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): CONF_NAME: LIGHT_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, }, { CONF_NAME: f"{LIGHT_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, }, ], diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index a3433a504b8..9b0c868f8cb 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_NAME, CONF_OFFSET, + CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SLAVE, CONF_STRUCTURE, @@ -578,6 +579,7 @@ async def test_struct_sensor(hass, cfg, regs, expected): { CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 0, } ] }, diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index b31ca12c48b..c620429aad2 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -210,6 +210,7 @@ async def test_all_switch(hass, call_type, regs, verify, expected): { CONF_NAME: SWITCH_NAME, CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -234,11 +235,13 @@ async def test_switch_service_turn(hass, caplog, mock_pymodbus): CONF_NAME: SWITCH_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, }, { CONF_NAME: f"{SWITCH_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, }, ], From 73065037563e56deb154582e9323ddb1372ad1ba Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 21 Jul 2021 07:49:54 +0200 Subject: [PATCH 043/112] Calculate count automatically in modbus platforms (#53116) --- homeassistant/components/modbus/__init__.py | 12 +++---- homeassistant/components/modbus/const.py | 20 ++++++------ homeassistant/components/modbus/validators.py | 32 ++++++++++--------- tests/components/modbus/test_init.py | 14 ++++---- tests/components/modbus/test_sensor.py | 2 +- 5 files changed, 39 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 9f851b4a235..8936ffc32ac 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -114,11 +114,7 @@ from .const import ( MODBUS_DOMAIN as DOMAIN, ) from .modbus import async_modbus_setup -from .validators import ( - number_validator, - scan_interval_validator, - sensor_schema_validator, -) +from .validators import number_validator, scan_interval_validator, struct_validator _LOGGER = logging.getLogger(__name__) @@ -145,7 +141,7 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( CALL_TYPE_REGISTER_INPUT, ] ), - vol.Optional(CONF_COUNT, default=1): cv.positive_int, + vol.Optional(CONF_COUNT): cv.positive_int, vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_INT): vol.In( [ DATA_TYPE_INT16, @@ -289,12 +285,12 @@ MODBUS_SCHEMA = vol.Schema( cv.ensure_list, [BINARY_SENSOR_SCHEMA] ), vol.Optional(CONF_CLIMATES): vol.All( - cv.ensure_list, [vol.All(CLIMATE_SCHEMA, sensor_schema_validator)] + cv.ensure_list, [vol.All(CLIMATE_SCHEMA, struct_validator)] ), vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHT_SCHEMA]), vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.All(SENSOR_SCHEMA, sensor_schema_validator)] + cv.ensure_list, [vol.All(SENSOR_SCHEMA, struct_validator)] ), vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]), vol.Optional(CONF_FANS): vol.All(cv.ensure_list, [FAN_SCHEMA]), diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 62319bcde85..49b7683435e 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -111,16 +111,16 @@ DEFAULT_SCAN_INTERVAL = 15 # seconds DEFAULT_SLAVE = 1 DEFAULT_STRUCTURE_PREFIX = ">f" DEFAULT_STRUCT_FORMAT = { - DATA_TYPE_INT16: "h", - DATA_TYPE_INT32: "i", - DATA_TYPE_INT64: "q", - DATA_TYPE_UINT16: "H", - DATA_TYPE_UINT32: "I", - DATA_TYPE_UINT64: "Q", - DATA_TYPE_FLOAT16: "e", - DATA_TYPE_FLOAT32: "f", - DATA_TYPE_FLOAT64: "d", - DATA_TYPE_STRING: "s", + 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/validators.py b/homeassistant/components/modbus/validators.py index e2777547ce4..9d72b611adc 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -40,7 +40,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -old_data_types = { +OLD_DATA_TYPES = { DATA_TYPE_INT: { 1: DATA_TYPE_INT16, 2: DATA_TYPE_INT32, @@ -59,39 +59,41 @@ old_data_types = { } -def sensor_schema_validator(config): +def struct_validator(config): """Sensor schema validator.""" data_type = config[CONF_DATA_TYPE] - count = config[CONF_COUNT] + count = config.get(CONF_COUNT, 1) 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]: - error = f"{name} {name} with {data_type} is not valid, trying to convert" + error = f"{name} with {data_type} is not valid, trying to convert" _LOGGER.warning(error) try: - data_type = old_data_types[data_type][count] + data_type = OLD_DATA_TYPES[data_type][config.get(CONF_COUNT, 1)] except KeyError as exp: - raise vol.Invalid("cannot convert automatically") from exp - + error = f"{name} cannot convert automatically {data_type}" + raise vol.Invalid(error) from exp if config[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: - try: - structure = f">{DEFAULT_STRUCT_FORMAT[data_type]}" - except KeyError as exp: - raise vol.Invalid(f"Modbus error {data_type} unknown in {name}") from exp + if structure: + error = f"{name} structure: cannot be mixed with {data_type}" + raise vol.Invalid(error) + structure = f">{DEFAULT_STRUCT_FORMAT[data_type][0]}" + if CONF_COUNT not in config: + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type][1] else: if not structure: - raise vol.Invalid( - f"Error in sensor {config[CONF_NAME]}. The `{CONF_STRUCTURE}` field can not be empty " - f"if the parameter `{CONF_DATA_TYPE}` is set to the `{DATA_TYPE_CUSTOM}`" + error = ( + f"Error in sensor {name}. The `{CONF_STRUCTURE}` field can not be empty" ) - + raise vol.Invalid(error) try: size = struct.calcsize(structure) except struct.error as err: raise vol.Invalid(f"Error in {name} structure: {str(err)}") from err + count = config.get(CONF_COUNT, 1) bytecount = count * 2 if bytecount != size: raise vol.Invalid( diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index ec68b98efa0..8b8d063bf02 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -55,7 +55,7 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( number_validator, - sensor_schema_validator, + struct_validator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( @@ -144,12 +144,12 @@ async def test_number_validator(): }, ], ) -async def test_ok_sensor_schema_validator(do_config): +async def test_ok_struct_validator(do_config): """Test struct validator.""" try: - sensor_schema_validator(do_config) + struct_validator(do_config) except vol.Invalid: - pytest.fail("Sensor_schema_validator unexpected exception") + pytest.fail("struct_validator unexpected exception") @pytest.mark.parametrize( @@ -186,13 +186,13 @@ async def test_ok_sensor_schema_validator(do_config): }, ], ) -async def test_exception_sensor_schema_validator(do_config): +async def test_exception_struct_validator(do_config): """Test struct validator.""" try: - sensor_schema_validator(do_config) + struct_validator(do_config) except vol.Invalid: return - pytest.fail("Sensor_schema_validator missing exception") + pytest.fail("struct_validator missing exception") @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 9b0c868f8cb..f01a3ef9da5 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -174,7 +174,7 @@ async def test_config_sensor(hass, mock_modbus): CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "", }, - "Error in sensor test_sensor. The `structure` field can not be empty if the parameter `data_type` is set to the `custom`", + "Error in sensor test_sensor. The `structure` field can not be empty", ), ( { From 2cf930f3bd1505ac48030854f2d82dafe7729d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 21 Jul 2021 08:46:01 +0200 Subject: [PATCH 044/112] Netatmo, use nameclass (#53247) --- homeassistant/components/netatmo/sensor.py | 344 ++++++++++++--------- 1 file changed, 206 insertions(+), 138 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 917d04d1b4d..46bb06149cd 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,5 +1,8 @@ """Support for the Netatmo Weather Service.""" +from __future__ import annotations + import logging +from typing import NamedTuple from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( @@ -51,136 +54,172 @@ SUPPORTED_PUBLIC_SENSOR_TYPES = [ "sum_rain_24", ] -# sensor type: [name, netatmo name, unit of measurement, icon, device class, enable default] -SENSOR_TYPES = { - "temperature": [ + +class SensorMetadata(NamedTuple): + """Metadata for an individual sensor.""" + + name: str + netatmo_name: str + enable_default: bool + unit: str | None = None + icon: str | None = None + device_class: str | None = None + + +SENSOR_TYPES: dict[str, SensorMetadata] = { + "temperature": SensorMetadata( "Temperature", - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - True, - ], - "temp_trend": [ + netatmo_name="Temperature", + enable_default=True, + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "temp_trend": SensorMetadata( "Temperature trend", - "temp_trend", - None, - "mdi:trending-up", - None, - False, - ], - "co2": [ + netatmo_name="temp_trend", + enable_default=False, + icon="mdi:trending-up", + ), + "co2": SensorMetadata( "CO2", - "CO2", - CONCENTRATION_PARTS_PER_MILLION, - None, - DEVICE_CLASS_CO2, - True, - ], - "pressure": [ + netatmo_name="CO2", + unit=CONCENTRATION_PARTS_PER_MILLION, + enable_default=True, + device_class=DEVICE_CLASS_CO2, + ), + "pressure": SensorMetadata( "Pressure", - "Pressure", - PRESSURE_MBAR, - None, - DEVICE_CLASS_PRESSURE, - True, - ], - "pressure_trend": [ + netatmo_name="Pressure", + enable_default=True, + unit=PRESSURE_MBAR, + device_class=DEVICE_CLASS_PRESSURE, + ), + "pressure_trend": SensorMetadata( "Pressure trend", - "pressure_trend", - None, - "mdi:trending-up", - None, - False, - ], - "noise": ["Noise", "Noise", SOUND_PRESSURE_DB, "mdi:volume-high", None, True], - "humidity": ["Humidity", "Humidity", PERCENTAGE, None, DEVICE_CLASS_HUMIDITY, True], - "rain": ["Rain", "Rain", LENGTH_MILLIMETERS, "mdi:weather-rainy", None, True], - "sum_rain_1": [ + netatmo_name="pressure_trend", + enable_default=False, + icon="mdi:trending-up", + ), + "noise": SensorMetadata( + "Noise", + netatmo_name="Noise", + enable_default=True, + unit=SOUND_PRESSURE_DB, + icon="mdi:volume-high", + ), + "humidity": SensorMetadata( + "Humidity", + netatmo_name="Humidity", + enable_default=True, + unit=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + "rain": SensorMetadata( + "Rain", + netatmo_name="Rain", + enable_default=True, + unit=LENGTH_MILLIMETERS, + icon="mdi:weather-rainy", + ), + "sum_rain_1": SensorMetadata( "Rain last hour", - "sum_rain_1", - LENGTH_MILLIMETERS, - "mdi:weather-rainy", - None, - False, - ], - "sum_rain_24": [ + enable_default=False, + netatmo_name="sum_rain_1", + unit=LENGTH_MILLIMETERS, + icon="mdi:weather-rainy", + ), + "sum_rain_24": SensorMetadata( "Rain today", - "sum_rain_24", - LENGTH_MILLIMETERS, - "mdi:weather-rainy", - None, - True, - ], - "battery_percent": [ + enable_default=True, + netatmo_name="sum_rain_24", + unit=LENGTH_MILLIMETERS, + icon="mdi:weather-rainy", + ), + "battery_percent": SensorMetadata( "Battery Percent", - "battery_percent", - PERCENTAGE, - None, - DEVICE_CLASS_BATTERY, - True, - ], - "windangle": ["Direction", "WindAngle", None, "mdi:compass-outline", None, True], - "windangle_value": [ + netatmo_name="battery_percent", + enable_default=True, + unit=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + "windangle": SensorMetadata( + "Direction", + netatmo_name="WindAngle", + enable_default=True, + icon="mdi:compass-outline", + ), + "windangle_value": SensorMetadata( "Angle", - "WindAngle", - DEGREE, - "mdi:compass-outline", - None, - False, - ], - "windstrength": [ + netatmo_name="WindAngle", + enable_default=False, + unit=DEGREE, + icon="mdi:compass-outline", + ), + "windstrength": SensorMetadata( "Wind Strength", - "WindStrength", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - True, - ], - "gustangle": [ + netatmo_name="WindStrength", + enable_default=True, + unit=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + "gustangle": SensorMetadata( "Gust Direction", - "GustAngle", - None, - "mdi:compass-outline", - None, - False, - ], - "gustangle_value": [ + netatmo_name="GustAngle", + enable_default=False, + icon="mdi:compass-outline", + ), + "gustangle_value": SensorMetadata( "Gust Angle", - "GustAngle", - DEGREE, - "mdi:compass-outline", - None, - False, - ], - "guststrength": [ + netatmo_name="GustAngle", + enable_default=False, + unit=DEGREE, + icon="mdi:compass-outline", + ), + "guststrength": SensorMetadata( "Gust Strength", - "GustStrength", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - False, - ], - "reachable": ["Reachability", "reachable", None, "mdi:signal", None, False], - "rf_status": ["Radio", "rf_status", None, "mdi:signal", None, False], - "rf_status_lvl": [ + netatmo_name="GustStrength", + enable_default=False, + unit=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + "reachable": SensorMetadata( + "Reachability", + netatmo_name="reachable", + enable_default=False, + icon="mdi:signal", + ), + "rf_status": SensorMetadata( + "Radio", + netatmo_name="rf_status", + enable_default=False, + icon="mdi:signal", + ), + "rf_status_lvl": SensorMetadata( "Radio Level", - "rf_status", - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - None, - DEVICE_CLASS_SIGNAL_STRENGTH, - False, - ], - "wifi_status": ["Wifi", "wifi_status", None, "mdi:wifi", None, False], - "wifi_status_lvl": [ + netatmo_name="rf_status", + enable_default=False, + unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), + "wifi_status": SensorMetadata( + "Wifi", + netatmo_name="wifi_status", + enable_default=False, + icon="mdi:wifi", + ), + "wifi_status_lvl": SensorMetadata( "Wifi Level", - "wifi_status", - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - None, - DEVICE_CLASS_SIGNAL_STRENGTH, - False, - ], - "health_idx": ["Health", "health_idx", None, "mdi:cloud", None, True], + netatmo_name="wifi_status", + enable_default=False, + unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), + "health_idx": SensorMetadata( + "Health", + enable_default=True, + netatmo_name="health_idx", + icon="mdi:cloud", + ), } MODULE_TYPE_OUTDOOR = "NAModule1" @@ -188,11 +227,41 @@ MODULE_TYPE_WIND = "NAModule2" MODULE_TYPE_RAIN = "NAModule3" MODULE_TYPE_INDOOR = "NAModule4" + +class BatteryData(NamedTuple): + """Metadata for a batter.""" + + full: int + high: int + medium: int + low: int + + BATTERY_VALUES = { - MODULE_TYPE_WIND: {"Full": 5590, "High": 5180, "Medium": 4770, "Low": 4360}, - MODULE_TYPE_RAIN: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, - MODULE_TYPE_INDOOR: {"Full": 5500, "High": 5280, "Medium": 4920, "Low": 4560}, - MODULE_TYPE_OUTDOOR: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, + MODULE_TYPE_WIND: BatteryData( + full=5590, + high=5180, + medium=4770, + low=4360, + ), + MODULE_TYPE_RAIN: BatteryData( + full=5500, + high=5000, + medium=4500, + low=4000, + ), + MODULE_TYPE_INDOOR: BatteryData( + full=5500, + high=5280, + medium=4920, + low=4560, + ), + MODULE_TYPE_OUTDOOR: BatteryData( + full=5500, + high=5000, + medium=4500, + low=4000, + ), } PUBLIC = "public" @@ -331,6 +400,8 @@ class NetatmoSensor(NetatmoBase, SensorEntity): """Initialize the sensor.""" super().__init__(data_handler) + metadata: SensorMetadata = SENSOR_TYPES[sensor_type] + self._data_classes.append( {"name": data_class_name, SIGNAL_NAME: data_class_name} ) @@ -353,16 +424,14 @@ class NetatmoSensor(NetatmoBase, SensorEntity): f"{module_info.get('module_name', device['type'])}" ) - self._attr_name = ( - f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[sensor_type][0]}" - ) + self._attr_name = f"{MANUFACTURER} {self._device_name} {metadata.name}" self.type = sensor_type - self._attr_device_class = SENSOR_TYPES[self.type][4] - self._attr_icon = SENSOR_TYPES[self.type][3] - self._attr_unit_of_measurement = SENSOR_TYPES[self.type][2] + self._attr_device_class = metadata.device_class + self._attr_icon = metadata.icon + self._attr_unit_of_measurement = metadata.unit self._model = device["type"] self._attr_unique_id = f"{self._id}-{self.type}" - self._attr_entity_registry_enabled_default = SENSOR_TYPES[self.type][5] + self._attr_entity_registry_enabled_default = metadata.enable_default @property def available(self): @@ -395,7 +464,7 @@ class NetatmoSensor(NetatmoBase, SensorEntity): return try: - state = data[SENSOR_TYPES[self.type][1]] + state = data[SENSOR_TYPES[self.type].netatmo_name] if self.type in {"temperature", "pressure", "sum_rain_1"}: self._attr_state = round(state, 1) elif self.type in {"windangle_value", "gustangle_value"}: @@ -449,15 +518,15 @@ def process_angle(angle: int) -> str: def process_battery(data: int, model: str) -> str: """Process battery data and return string for display.""" - values = BATTERY_VALUES[model] + battery_data = BATTERY_VALUES[model] - if data >= values["Full"]: + if data >= battery_data.full: return "Full" - if data >= values["High"]: + if data >= battery_data.high: return "High" - if data >= values["Medium"]: + if data >= battery_data.medium: return "Medium" - if data >= values["Low"]: + if data >= battery_data.low: return "Low" return "Very Low" @@ -518,6 +587,7 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): SIGNAL_NAME: self._signal_name, } ) + metadata: SensorMetadata = SENSOR_TYPES[sensor_type] self.type = sensor_type self.area = area @@ -525,12 +595,10 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): self._area_name = area.area_name self._id = self._area_name self._device_name = f"{self._area_name}" - self._attr_name = ( - f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[self.type][0]}" - ) - self._attr_device_class = SENSOR_TYPES[self.type][4] - self._attr_icon = SENSOR_TYPES[self.type][3] - self._attr_unit_of_measurement = SENSOR_TYPES[self.type][2] + self._attr_name = f"{MANUFACTURER} {self._device_name} {metadata.name}" + self._attr_device_class = metadata.device_class + self._attr_icon = metadata.icon + self._attr_unit_of_measurement = metadata.unit self._show_on_map = area.show_on_map self._attr_unique_id = f"{self._device_name.replace(' ', '-')}-{self.type}" self._model = PUBLIC From 930db7167e3bf6e140ecfda8743626fdb6c06ac9 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 02:53:53 -0400 Subject: [PATCH 045/112] Code quality improvements for goalzero (#53260) --- homeassistant/components/goalzero/__init__.py | 42 +++++++++++-------- .../components/goalzero/binary_sensor.py | 13 +++--- .../components/goalzero/config_flow.py | 4 +- homeassistant/components/goalzero/const.py | 4 -- homeassistant/components/goalzero/sensor.py | 9 ++-- homeassistant/components/goalzero/switch.py | 17 ++++---- 6 files changed, 46 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index fe2d5cc695e..308934819cd 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,4 +1,6 @@ """The Goal Zero Yeti integration.""" +from __future__ import annotations + import logging from goalzero import Yeti, exceptions @@ -7,10 +9,20 @@ from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSO from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_HOST, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_HOST, + CONF_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -41,7 +53,7 @@ async def async_setup_entry(hass, entry): try: await api.init_connect() except exceptions.ConnectError as ex: - _LOGGER.warning("Failed to connect: %s", ex) + _LOGGER.warning("Failed to connect to device %s", ex) raise ConfigEntryNotReady from ex async def async_update_data(): @@ -88,23 +100,19 @@ class YetiEntity(CoordinatorEntity): self.api = api self._name = name self._server_unique_id = server_unique_id - self._device_class = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" - info = { - "identifiers": {(DOMAIN, self._server_unique_id)}, - "manufacturer": "Goal Zero", - "name": self._name, - } + model = sw_version = None if self.api.sysdata: - info["model"] = self.api.sysdata["model"] + model = self.api.sysdata[ATTR_MODEL] if self.api.data: - info["sw_version"] = self.api.data["firmwareVersion"] - return info - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class + 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), + } diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index aec9fdb0354..74776eb51b5 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -12,7 +12,7 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the Goal Zero Yeti sensor.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] - sensors = [ + async_add_entities( YetiBinarySensor( goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], @@ -21,8 +21,7 @@ async def async_setup_entry(hass, entry, async_add_entities): entry.entry_id, ) for sensor_name in BINARY_SENSOR_DICT - ] - async_add_entities(sensors) + ) class YetiBinarySensor(YetiEntity, BinarySensorEntity): @@ -47,23 +46,23 @@ class YetiBinarySensor(YetiEntity, BinarySensorEntity): self._device_class = variable_info[1] @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return f"{self._name} {self._condition_name}" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id of the sensor.""" return f"{self._server_unique_id}/{self._condition_name}" @property - def is_on(self): + def is_on(self) -> bool: """Return if the service is on.""" if self.api.data: return self.api.data[self._condition] == 1 return False @property - def icon(self): + def icon(self) -> str: """Icon to use in the frontend, if any.""" return self._icon diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index 50450f95a69..4c525de9c7d 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -63,7 +63,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} if user_input is not None: @@ -98,7 +98,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_try_connect(self, host): + async def _async_try_connect(self, host) -> tuple: """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 4c860a5e0e4..da4a6ee4ad6 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -34,10 +34,6 @@ from homeassistant.const import ( ATTRIBUTION = "Data provided by Goal Zero" ATTR_DEFAULT_ENABLED = "default_enabled" -CONF_IDENTIFIERS = "identifiers" -CONF_MANUFACTURER = "manufacturer" -CONF_MODEL = "model" -CONF_SW_VERSION = "sw_version" DATA_KEY_COORDINATOR = "coordinator" DOMAIN = "goalzero" DEFAULT_NAME = "Yeti" diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index ccb39ae0813..f64d6d772c8 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -1,4 +1,6 @@ """Support for Goal Zero Yeti Sensors.""" +from __future__ import annotations + from homeassistant.components.sensor import ATTR_LAST_RESET, ATTR_STATE_CLASS from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -40,20 +42,19 @@ class YetiSensor(YetiEntity): def __init__(self, api, coordinator, name, sensor_name, server_unique_id): """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_name = f"{name} {sensor.get(ATTR_NAME)}" self._attr_unique_id = f"{self._server_unique_id}/{sensor_name}" self._attr_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) self._attr_entity_registry_enabled_default = sensor.get(ATTR_DEFAULT_ENABLED) - self._device_class = sensor.get(ATTR_DEVICE_CLASS) + self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) self._attr_last_reset = sensor.get(ATTR_LAST_RESET) self._attr_state_class = sensor.get(ATTR_STATE_CLASS) @property - def state(self): + def state(self) -> str | None: """Return the state.""" if self.api.data: return self.api.data.get(self._condition) + return None diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index dd4c9deae3e..92808ef5f43 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -1,4 +1,6 @@ """Support for Goal Zero Yeti Switches.""" +from __future__ import annotations + from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME @@ -10,7 +12,7 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the Goal Zero Yeti switch.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] - switches = [ + async_add_entities( YetiSwitch( goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], @@ -19,8 +21,7 @@ async def async_setup_entry(hass, entry, async_add_entities): entry.entry_id, ) for switch_name in SWITCH_DICT - ] - async_add_entities(switches) + ) class YetiSwitch(YetiEntity, SwitchEntity): @@ -36,27 +37,25 @@ class YetiSwitch(YetiEntity, SwitchEntity): ): """Initialize a Goal Zero Yeti switch.""" super().__init__(api, coordinator, name, server_unique_id) - self._condition = switch_name - self._condition_name = SWITCH_DICT[switch_name] @property - def name(self): + def name(self) -> str: """Return the name of the switch.""" return f"{self._name} {self._condition_name}" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id of the switch.""" return f"{self._server_unique_id}/{self._condition}" @property - def is_on(self): + def is_on(self) -> bool: """Return state of the switch.""" if self.api.data: return self.api.data[self._condition] - return None + return False async def async_turn_off(self, **kwargs): """Turn off the switch.""" From 5e059c7f55ebea6a77019570e60066d6c729d32c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Jul 2021 23:55:34 -0700 Subject: [PATCH 046/112] Fix lint on dev (#53265) --- homeassistant/components/mill/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 5b0cf7efd73..8b68d0ebe38 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -18,7 +18,7 @@ async def async_setup_entry(hass, entry, async_add_entities): dev = [] for heater in mill_data_connection.heaters.values(): - for sensor_type in [CONSUMPTION_TODAY, CONSUMPTION_YEAR]: + for sensor_type in (CONSUMPTION_TODAY, CONSUMPTION_YEAR): dev.append( MillHeaterEnergySensor(heater, mill_data_connection, sensor_type) ) From 9b2d98f0277f31f71bdd50e27f91a916870f41a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 21 Jul 2021 08:56:29 +0200 Subject: [PATCH 047/112] Tibber, use nameclass (#53242) --- homeassistant/components/tibber/sensor.py | 137 ++++++++++++---------- 1 file changed, 73 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 9ba175db57d..c48f4936200 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta from enum import Enum import logging from random import randrange +from typing import NamedTuple import aiohttp @@ -58,8 +58,7 @@ class ResetType(Enum): NEVER = "never" -@dataclass -class TibberSensorMetadata: +class TibberSensorMetadata(NamedTuple): """Metadata for an individual Tibber sensor.""" name: str @@ -71,119 +70,129 @@ class TibberSensorMetadata: RT_SENSOR_MAP: dict[str, TibberSensorMetadata] = { "averagePower": TibberSensorMetadata( - "average power", DEVICE_CLASS_POWER, POWER_WATT + "average power", + device_class=DEVICE_CLASS_POWER, + unit=POWER_WATT, ), "power": TibberSensorMetadata( "power", - DEVICE_CLASS_POWER, - POWER_WATT, + device_class=DEVICE_CLASS_POWER, + unit=POWER_WATT, ), "powerProduction": TibberSensorMetadata( - "power production", DEVICE_CLASS_POWER, POWER_WATT + "power production", + device_class=DEVICE_CLASS_POWER, + unit=POWER_WATT, + ), + "minPower": TibberSensorMetadata( + "min power", + device_class=DEVICE_CLASS_POWER, + unit=POWER_WATT, + ), + "maxPower": TibberSensorMetadata( + "max power", + device_class=DEVICE_CLASS_POWER, + unit=POWER_WATT, ), - "minPower": TibberSensorMetadata("min power", DEVICE_CLASS_POWER, POWER_WATT), - "maxPower": TibberSensorMetadata("max power", DEVICE_CLASS_POWER, POWER_WATT), "accumulatedConsumption": TibberSensorMetadata( "accumulated consumption", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ResetType.DAILY, + device_class=DEVICE_CLASS_ENERGY, + unit=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, ), "accumulatedConsumptionLastHour": TibberSensorMetadata( "accumulated consumption current hour", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ResetType.HOURLY, + device_class=DEVICE_CLASS_ENERGY, + unit=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.HOURLY, ), "accumulatedProduction": TibberSensorMetadata( "accumulated production", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ResetType.DAILY, + device_class=DEVICE_CLASS_ENERGY, + unit=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, ), "accumulatedProductionLastHour": TibberSensorMetadata( "accumulated production current hour", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ResetType.HOURLY, + device_class=DEVICE_CLASS_ENERGY, + unit=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.HOURLY, ), "lastMeterConsumption": TibberSensorMetadata( "last meter consumption", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + unit=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, ), "lastMeterProduction": TibberSensorMetadata( "last meter production", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + unit=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, ), "voltagePhase1": TibberSensorMetadata( "voltage phase1", - DEVICE_CLASS_VOLTAGE, - ELECTRIC_POTENTIAL_VOLT, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + unit=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, ), "voltagePhase2": TibberSensorMetadata( "voltage phase2", - DEVICE_CLASS_VOLTAGE, - ELECTRIC_POTENTIAL_VOLT, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + unit=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, ), "voltagePhase3": TibberSensorMetadata( "voltage phase3", - DEVICE_CLASS_VOLTAGE, - ELECTRIC_POTENTIAL_VOLT, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + unit=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, ), "currentL1": TibberSensorMetadata( "current L1", - DEVICE_CLASS_CURRENT, - ELECTRIC_CURRENT_AMPERE, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + unit=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, ), "currentL2": TibberSensorMetadata( "current L2", - DEVICE_CLASS_CURRENT, - ELECTRIC_CURRENT_AMPERE, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + unit=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, ), "currentL3": TibberSensorMetadata( "current L3", - DEVICE_CLASS_CURRENT, - ELECTRIC_CURRENT_AMPERE, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + unit=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, ), "signalStrength": TibberSensorMetadata( "signal strength", - DEVICE_CLASS_SIGNAL_STRENGTH, - SIGNAL_STRENGTH_DECIBELS, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + unit=SIGNAL_STRENGTH_DECIBELS, + state_class=STATE_CLASS_MEASUREMENT, ), "accumulatedReward": TibberSensorMetadata( "accumulated reward", - DEVICE_CLASS_MONETARY, - None, - STATE_CLASS_MEASUREMENT, - ResetType.DAILY, + device_class=DEVICE_CLASS_MONETARY, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, ), "accumulatedCost": TibberSensorMetadata( "accumulated cost", - DEVICE_CLASS_MONETARY, - None, - STATE_CLASS_MEASUREMENT, - ResetType.DAILY, + device_class=DEVICE_CLASS_MONETARY, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, ), "powerFactor": TibberSensorMetadata( "power factor", - DEVICE_CLASS_POWER_FACTOR, - PERCENTAGE, - STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER_FACTOR, + unit=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), } @@ -362,7 +371,7 @@ class TibberSensorRT(TibberSensor): self._attr_state = initial_state self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{metadata.name}" - if metadata.name in ["accumulated cost", "accumulated reward"]: + if metadata.name in ("accumulated cost", "accumulated reward"): self._attr_unit_of_measurement = tibber_home.currency else: self._attr_unit_of_measurement = metadata.unit From 4546e1467430588c50dc18d05a40425c2c7dee53 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 21 Jul 2021 10:02:07 +0200 Subject: [PATCH 048/112] Fix MQTT to allow setting an unknown Select state (#53227) --- homeassistant/components/mqtt/select.py | 5 ++++- tests/components/mqtt/test_select.py | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 98643917788..4f4d3fbb663 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -126,7 +126,10 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): if value_template is not None: payload = value_template.async_render_with_possible_json_value(payload) - if payload not in self.options: + if payload.lower() == "none": + payload = None + + if payload is not None and payload not in self.options: _LOGGER.error( "Invalid option for %s: '%s' (valid options: %s)", self.entity_id, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 5dad989a5cf..f2e48e10dc5 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -15,7 +15,7 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -122,6 +122,13 @@ async def test_value_template(hass, mqtt_mock): state = hass.states.get("select.test_select") assert state.state == "beer" + async_fire_mqtt_message(hass, topic, '{"val": null}') + + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == STATE_UNKNOWN + async def test_run_select_service_optimistic(hass, mqtt_mock): """Test that set_value service works in optimistic mode.""" From 18ec0544b9350cb2e77885e53cd05af9fb1931ea Mon Sep 17 00:00:00 2001 From: muppet3000 Date: Wed, 21 Jul 2021 09:16:02 +0100 Subject: [PATCH 049/112] Allow for alternative external Growatt servers (#53102) --- .../components/growatt_server/config_flow.py | 11 ++++++++--- .../components/growatt_server/const.py | 8 ++++++++ .../components/growatt_server/sensor.py | 8 ++++++-- .../components/growatt_server/strings.json | 5 +++-- .../growatt_server/test_config_flow.py | 18 +++++++++++++----- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index cc1457d3687..45f56a327b2 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -3,10 +3,10 @@ import growattServer import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback -from .const import CONF_PLANT_ID, DOMAIN +from .const import CONF_PLANT_ID, DEFAULT_URL, DOMAIN, SERVER_URLS class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -24,7 +24,11 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _async_show_user_form(self, errors=None): """Show the form to the user.""" data_schema = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS), + } ) return self.async_show_form( @@ -36,6 +40,7 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not user_input: return self._async_show_user_form() + self.api.server_url = user_input[CONF_URL] login_response = await self.hass.async_add_executor_job( self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 4dc09988e6f..0b11e9994ca 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -5,6 +5,14 @@ DEFAULT_PLANT_ID = "0" DEFAULT_NAME = "Growatt" +SERVER_URLS = [ + "https://server.growatt.com/", + "https://server-us.growatt.com", + "http://server.smten.com/", +] + +DEFAULT_URL = SERVER_URLS[0] + DOMAIN = "growatt_server" PLATFORMS = ["sensor"] diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 78fd24623d8..9d0aa098051 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, + CONF_URL, CONF_USERNAME, CURRENCY_EURO, DEVICE_CLASS_BATTERY, @@ -33,7 +34,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, dt -from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DOMAIN +from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DEFAULT_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -554,6 +555,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 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, } ) @@ -579,7 +581,7 @@ def get_device_list(api, config): # Log in to api and fetch first plant if no plant id is defined. login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) if not login_response["success"] and login_response["errCode"] == "102": - _LOGGER.error("Username or Password may be incorrect!") + _LOGGER.error("Username, Password or URL may be incorrect!") return user_id = login_response["userId"] if plant_id == DEFAULT_PLANT_ID: @@ -596,9 +598,11 @@ 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] name = config[CONF_NAME] api = growattServer.GrowattApi() + api.server_url = url devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index e8d4f395c7b..45e25c0ba33 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -17,11 +17,12 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "url": "[%key:common::config_flow::data::url%]" }, "title": "Enter your Growatt information" } } }, "title": "Growatt Server" -} +} \ No newline at end of file diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index cc11c2f8bf2..662448c8118 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -3,12 +3,20 @@ from copy import deepcopy from unittest.mock import patch from homeassistant import config_entries, data_entry_flow -from homeassistant.components.growatt_server.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.growatt_server.const import ( + CONF_PLANT_ID, + DEFAULT_URL, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from tests.common import MockConfigEntry -FIXTURE_USER_INPUT = {CONF_USERNAME: "username", CONF_PASSWORD: "password"} +FIXTURE_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_URL: DEFAULT_URL, +} GROWATT_PLANT_LIST_RESPONSE = { "data": [ @@ -45,8 +53,8 @@ async def test_show_authenticate_form(hass): assert result["step_id"] == "user" -async def test_incorrect_username(hass): - """Test that it shows the appropriate error when an incorrect username is entered.""" +async def test_incorrect_login(hass): + """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) From e9ce3c57cd18d42b234f9322c7e5bcefd1bc6e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 21 Jul 2021 10:25:46 +0200 Subject: [PATCH 050/112] Adax heaters (#50998) Co-authored-by: G Johansson <62932417+gjohansson-ST@users.noreply.github.com> Co-authored-by: Franck Nijhof --- .coveragerc | 2 + CODEOWNERS | 1 + homeassistant/components/adax/__init__.py | 18 +++ homeassistant/components/adax/climate.py | 152 ++++++++++++++++++ homeassistant/components/adax/config_flow.py | 71 ++++++++ homeassistant/components/adax/const.py | 5 + homeassistant/components/adax/manifest.json | 13 ++ homeassistant/components/adax/strings.json | 20 +++ .../components/adax/translations/en.json | 21 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/adax/__init__.py | 1 + tests/components/adax/test_config_flow.py | 78 +++++++++ 14 files changed, 389 insertions(+) create mode 100644 homeassistant/components/adax/__init__.py create mode 100644 homeassistant/components/adax/climate.py create mode 100644 homeassistant/components/adax/config_flow.py create mode 100644 homeassistant/components/adax/const.py create mode 100644 homeassistant/components/adax/manifest.json create mode 100644 homeassistant/components/adax/strings.json create mode 100644 homeassistant/components/adax/translations/en.json create mode 100644 tests/components/adax/__init__.py create mode 100644 tests/components/adax/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 84577e99197..49c2cb98424 100644 --- a/.coveragerc +++ b/.coveragerc @@ -20,6 +20,8 @@ omit = homeassistant/components/acmeda/helpers.py homeassistant/components/acmeda/hub.py homeassistant/components/acmeda/sensor.py + homeassistant/components/adax/__init__.py + homeassistant/components/adax/climate.py homeassistant/components/adguard/__init__.py homeassistant/components/adguard/const.py homeassistant/components/adguard/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index acd2b6d44c3..e5a9bcd5823 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -22,6 +22,7 @@ homeassistant/scripts/check_config.py @kellerza homeassistant/components/abode/* @shred86 homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray +homeassistant/components/adax/* @danielhiversen homeassistant/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 homeassistant/components/aemet/* @noltari diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py new file mode 100644 index 00000000000..0a14648af26 --- /dev/null +++ b/homeassistant/components/adax/__init__.py @@ -0,0 +1,18 @@ +"""The Adax integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Adax from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py new file mode 100644 index 00000000000..74e973ba6d5 --- /dev/null +++ b/homeassistant/components/adax/climate.py @@ -0,0 +1,152 @@ +"""Support for Adax wifi-enabled home heaters.""" +from __future__ import annotations + +import logging +from typing import Any + +from adax import Adax + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_PASSWORD, + PRECISION_WHOLE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ACCOUNT_ID + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Adax thermostat with config flow.""" + adax_data_handler = Adax( + entry.data[ACCOUNT_ID], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + + async_add_entities( + AdaxDevice(room, adax_data_handler) + for room in await adax_data_handler.get_rooms() + ) + + +class AdaxDevice(ClimateEntity): + """Representation of a heater.""" + + 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']}" + + @property + def name(self) -> str: + """Return the name of the device, if any.""" + return self._heater_data["name"] + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if self._heater_data["heatingEnabled"]: + return HVAC_MODE_HEAT + return HVAC_MODE_OFF + + @property + def icon(self) -> str: + """Return nice icon for heater.""" + if self.hvac_mode == HVAC_MODE_HEAT: + 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: + temperature = max( + self.min_temp, self._heater_data.get("targetTemperature", self.min_temp) + ) + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], temperature, True + ) + elif hvac_mode == HVAC_MODE_OFF: + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], self.min_temp, False + ) + else: + 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.""" + return self._heater_data.get("temperature") + + @property + def target_temperature(self) -> int | None: + """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) + if temperature is None: + return + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], temperature, True + ) + + async def async_update(self) -> None: + """Get the latest data.""" + for room in await self._adax_data_handler.get_rooms(): + if room["id"] == self._heater_data["id"]: + self._heater_data = room + return diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py new file mode 100644 index 00000000000..166278ef48d --- /dev/null +++ b/homeassistant/components/adax/config_flow.py @@ -0,0 +1,71 @@ +"""Config flow for Adax integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import adax +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD +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 .const import ACCOUNT_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(ACCOUNT_ID): int, vol.Required(CONF_PASSWORD): str} +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect.""" + account_id = data[ACCOUNT_ID] + password = data[CONF_PASSWORD].replace(" ", "") + + token = await adax.get_adax_token( + async_get_clientsession(hass), account_id, password + ) + if token is None: + _LOGGER.info("Adax: Failed to login to retrieve token") + raise CannotConnect + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Adax.""" + + 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: + self._async_abort_entries_match({ACCOUNT_ID: user_input[ACCOUNT_ID]}) + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[ACCOUNT_ID], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/adax/const.py b/homeassistant/components/adax/const.py new file mode 100644 index 00000000000..ecb83f9b0f7 --- /dev/null +++ b/homeassistant/components/adax/const.py @@ -0,0 +1,5 @@ +"""Constants for the Adax integration.""" +from typing import Final + +ACCOUNT_ID: Final = "account_id" +DOMAIN: Final = "adax" diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json new file mode 100644 index 00000000000..36106290ed6 --- /dev/null +++ b/homeassistant/components/adax/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adax", + "name": "Adax", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adax", + "requirements": [ + "adax==0.0.1" + ], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json new file mode 100644 index 00000000000..0f7aac83f5a --- /dev/null +++ b/homeassistant/components/adax/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "account_id": "Account ID", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/adax/translations/en.json b/homeassistant/components/adax/translations/en.json new file mode 100644 index 00000000000..a5a204c93f8 --- /dev/null +++ b/homeassistant/components/adax/translations/en.json @@ -0,0 +1,21 @@ +{ + "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", + "password": "Password", + "account_id": "Account ID" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 45a339ebed1..1321c01b27d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = [ "abode", "accuweather", "acmeda", + "adax", "adguard", "advantage_air", "aemet", diff --git a/requirements_all.txt b/requirements_all.txt index a0b2fa1d554..0559e927aec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -104,6 +104,9 @@ adafruit-circuitpython-dht==3.6.0 # homeassistant.components.mcp23017 adafruit-circuitpython-mcp230xx==2.2.2 +# homeassistant.components.adax +adax==0.0.1 + # homeassistant.components.androidtv adb-shell[async]==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36cfed2598e..80f25c59e31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -47,6 +47,9 @@ abodepy==1.2.0 # homeassistant.components.accuweather accuweather==0.2.0 +# homeassistant.components.adax +adax==0.0.1 + # homeassistant.components.androidtv adb-shell[async]==0.3.4 diff --git a/tests/components/adax/__init__.py b/tests/components/adax/__init__.py new file mode 100644 index 00000000000..54a72856a85 --- /dev/null +++ b/tests/components/adax/__init__.py @@ -0,0 +1 @@ +"""Tests for the Adax integration.""" diff --git a/tests/components/adax/test_config_flow.py b/tests/components/adax/test_config_flow.py new file mode 100644 index 00000000000..f9638e52cbf --- /dev/null +++ b/tests/components/adax/test_config_flow.py @@ -0,0 +1,78 @@ +"""Test the Adax config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.adax.const import ACCOUNT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_DATA = { + ACCOUNT_ID: 12345, + CONF_PASSWORD: "pswd", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch("adax.get_adax_token", return_value="test_token",), patch( + "homeassistant.components.adax.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_DATA["account_id"] + assert result2["data"] == { + "account_id": TEST_DATA["account_id"], + "password": TEST_DATA["password"], + } + 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( + "adax.get_adax_token", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + + first_entry = MockConfigEntry( + domain="adax", + data=TEST_DATA, + unique_id=TEST_DATA[ACCOUNT_ID], + ) + first_entry.add_to_hass(hass) + + with patch("adax.get_adax_token", return_value="token"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" From 81c4d95afe9577001f4f12ef2b5a5d3ccf4dc396 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 05:31:50 -0400 Subject: [PATCH 051/112] Use entity class attributes for arduino (#52677) * Use entity class attributes for arduino * Revert state * tweak * tweak --- homeassistant/components/arduino/sensor.py | 19 +++-------------- homeassistant/components/arduino/switch.py | 24 ++++++---------------- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py index 588a652660a..fa624a7d167 100644 --- a/homeassistant/components/arduino/sensor.py +++ b/homeassistant/components/arduino/sensor.py @@ -35,24 +35,11 @@ class ArduinoSensor(SensorEntity): def __init__(self, name, pin, pin_type, board): """Initialize the sensor.""" self._pin = pin - self._name = name - self.pin_type = pin_type - self.direction = "in" - self._value = None + self._attr_name = name - board.set_mode(self._pin, self.direction, self.pin_type) + board.set_mode(self._pin, "in", pin_type) self._board = board - @property - def state(self): - """Return the state of the sensor.""" - return self._value - - @property - def name(self): - """Get the name of the sensor.""" - return self._name - def update(self): """Get the latest value from the pin.""" - self._value = self._board.get_analog_inputs()[self._pin][1] + self._attr_state = self._board.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/arduino/switch.py b/homeassistant/components/arduino/switch.py index 6ee742fd506..9368426b38e 100644 --- a/homeassistant/components/arduino/switch.py +++ b/homeassistant/components/arduino/switch.py @@ -43,11 +43,9 @@ class ArduinoSwitch(SwitchEntity): def __init__(self, pin, options, board): """Initialize the Pin.""" self._pin = pin - self._name = options[CONF_NAME] - self.pin_type = CONF_TYPE - self.direction = "out" + self._attr_name = options[CONF_NAME] - self._state = options[CONF_INITIAL] + self._attr_is_on = options[CONF_INITIAL] if options[CONF_NEGATE]: self.turn_on_handler = board.set_digital_out_low @@ -56,25 +54,15 @@ class ArduinoSwitch(SwitchEntity): self.turn_on_handler = board.set_digital_out_high self.turn_off_handler = board.set_digital_out_low - board.set_mode(self._pin, self.direction, self.pin_type) - (self.turn_on_handler if self._state else self.turn_off_handler)(pin) - - @property - def name(self): - """Get the name of the pin.""" - return self._name - - @property - def is_on(self): - """Return true if pin is high/on.""" - return self._state + board.set_mode(pin, "out", CONF_TYPE) + (self.turn_on_handler if self.is_on else self.turn_off_handler)(pin) def turn_on(self, **kwargs): """Turn the pin to high/on.""" - self._state = True + self._attr_is_on = True self.turn_on_handler(self._pin) def turn_off(self, **kwargs): """Turn the pin to low/off.""" - self._state = False + self._attr_is_on = False self.turn_off_handler(self._pin) From 02a7a2464a60c2d44dc6a8e66ca36579e8ddb93f Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 05:33:44 -0400 Subject: [PATCH 052/112] Use entity class attributes for atag (#52686) --- homeassistant/components/atag/__init__.py | 19 ++-------- homeassistant/components/atag/climate.py | 27 ++++--------- homeassistant/components/atag/sensor.py | 38 +++++++------------ homeassistant/components/atag/water_heater.py | 17 ++------- 4 files changed, 29 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index af5eff67f57..de785a3a317 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -75,27 +75,16 @@ class AtagEntity(CoordinatorEntity): super().__init__(coordinator) self._id = atag_id - self._name = DOMAIN.title() + self._attr_name = DOMAIN.title() + self._attr_unique_id = f"{coordinator.data.id}-{atag_id}" @property def device_info(self) -> DeviceInfo: """Return info for device registry.""" - device = self.coordinator.data.id - version = self.coordinator.data.apiversion return { - "identifiers": {(DOMAIN, device)}, + "identifiers": {(DOMAIN, self.coordinator.data.id)}, "name": "Atag Thermostat", "model": "Atag One", - "sw_version": version, + "sw_version": self.coordinator.data.apiversion, "manufacturer": "Atag", } - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self.coordinator.data.id}-{self._id}" diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index da7e6a14a73..6bafd59ab82 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -37,10 +37,14 @@ async def async_setup_entry(hass, entry, async_add_entities): class AtagThermostat(AtagEntity, ClimateEntity): """Atag climate device.""" - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS + _attr_hvac_modes = HVAC_MODES + _attr_preset_modes = list(PRESET_MAP.keys()) + _attr_supported_features = SUPPORT_FLAGS + + def __init__(self, coordinator, atag_id): + """Initialize an Atag climate device.""" + super().__init__(coordinator, atag_id) + self._attr_temperature_unit = coordinator.data.climate.temp_unit @property def hvac_mode(self) -> str | None: @@ -49,22 +53,12 @@ class AtagThermostat(AtagEntity, ClimateEntity): return self.coordinator.data.climate.hvac_mode return None - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return HVAC_MODES - @property def hvac_action(self) -> str | None: """Return the current running hvac operation.""" is_active = self.coordinator.data.climate.status return CURRENT_HVAC_HEAT if is_active else CURRENT_HVAC_IDLE - @property - def temperature_unit(self) -> str | None: - """Return the unit of measurement.""" - return self.coordinator.data.climate.temp_unit - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -81,11 +75,6 @@ class AtagThermostat(AtagEntity, ClimateEntity): preset = self.coordinator.data.climate.preset_mode return PRESET_INVERTED.get(preset) - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return list(PRESET_MAP.keys()) - async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" await self.coordinator.data.climate.set_temp(kwargs.get(ATTR_TEMPERATURE)) diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index 88ccbdc899f..93164bd14bf 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -36,7 +36,20 @@ class AtagSensor(AtagEntity, SensorEntity): def __init__(self, coordinator, sensor): """Initialize Atag sensor.""" super().__init__(coordinator, SENSORS[sensor]) - self._name = sensor + self._attr_name = sensor + 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 [ + PRESSURE_BAR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + PERCENTAGE, + TIME_HOURS, + ]: + self._attr_unit_of_measurement = coordinator.data.report[self._id].measure @property def state(self): @@ -47,26 +60,3 @@ class AtagSensor(AtagEntity, SensorEntity): def icon(self): """Return icon.""" return self.coordinator.data.report[self._id].icon - - @property - def device_class(self): - """Return deviceclass.""" - if self.coordinator.data.report[self._id].sensorclass in [ - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - ]: - return self.coordinator.data.report[self._id].sensorclass - return None - - @property - def unit_of_measurement(self): - """Return measure.""" - if self.coordinator.data.report[self._id].measure in [ - PRESSURE_BAR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - PERCENTAGE, - TIME_HOURS, - ]: - return self.coordinator.data.report[self._id].measure - return None diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index dac56edf89d..5fce2abf63e 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -22,15 +22,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AtagWaterHeater(AtagEntity, WaterHeaterEntity): """Representation of an ATAG water heater.""" - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS_HEATER - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS + _attr_operation_list = OPERATION_LIST + _attr_supported_features = SUPPORT_FLAGS_HEATER + _attr_temperature_unit = TEMP_CELSIUS @property def current_temperature(self): @@ -43,11 +37,6 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity): operation = self.coordinator.data.dhw.current_operation return operation if operation in self.operation_list else STATE_OFF - @property - def operation_list(self): - """List of available operation modes.""" - return OPERATION_LIST - async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if await self.coordinator.data.dhw.set_temp(kwargs.get(ATTR_TEMPERATURE)): From 462db1b4b2794e09d38d42093d2a0c33e805e98b Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 07:31:54 -0400 Subject: [PATCH 053/112] Add config flow to nfandroidtv (#51280) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/nfandroidtv/__init__.py | 70 +++- .../components/nfandroidtv/config_flow.py | 76 +++++ homeassistant/components/nfandroidtv/const.py | 28 ++ .../components/nfandroidtv/manifest.json | 6 +- .../components/nfandroidtv/notify.py | 300 +++++++----------- .../components/nfandroidtv/strings.json | 21 ++ .../nfandroidtv/translations/en.json | 21 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nfandroidtv/__init__.py | 31 ++ .../nfandroidtv/test_config_flow.py | 135 ++++++++ 14 files changed, 512 insertions(+), 185 deletions(-) create mode 100644 homeassistant/components/nfandroidtv/config_flow.py create mode 100644 homeassistant/components/nfandroidtv/const.py create mode 100644 homeassistant/components/nfandroidtv/strings.json create mode 100644 homeassistant/components/nfandroidtv/translations/en.json create mode 100644 tests/components/nfandroidtv/__init__.py create mode 100644 tests/components/nfandroidtv/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 49c2cb98424..83212125cb7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -693,6 +693,7 @@ omit = homeassistant/components/neurio_energy/sensor.py homeassistant/components/nexia/climate.py homeassistant/components/nextcloud/* + homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py diff --git a/CODEOWNERS b/CODEOWNERS index e5a9bcd5823..5fa884fda15 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -332,6 +332,7 @@ homeassistant/components/netdata/* @fabaff homeassistant/components/nexia/* @bdraco homeassistant/components/nextbus/* @vividboarder homeassistant/components/nextcloud/* @meichthys +homeassistant/components/nfandroidtv/* @tkdrob homeassistant/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 9965265e00d..90a76c1c747 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1 +1,69 @@ -"""The nfandroidtv component.""" +"""The NFAndroidTV integration.""" +import logging + +from notifications_android_tv.notifications import ConnectError, Notifications + +from homeassistant.components.notify import DOMAIN as NOTIFY +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +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 .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [NOTIFY] + + +async def async_setup(hass: HomeAssistant, config): + """Set up the NFAndroidTV component.""" + hass.data.setdefault(DOMAIN, {}) + # Iterate all entries for notify to only get nfandroidtv + if NOTIFY in config: + for entry in config[NOTIFY]: + if entry[CONF_PLATFORM] == DOMAIN: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NFAndroidTV from a config entry.""" + host = entry.data[CONF_HOST] + name = entry.data[CONF_NAME] + + try: + await hass.async_add_executor_job(Notifications, host) + except ConnectError as ex: + _LOGGER.warning("Failed to connect: %s", ex) + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + CONF_HOST: host, + CONF_NAME: name, + } + + hass.async_create_task( + discovery.async_load_platform( + hass, NOTIFY, DOMAIN, hass.data[DOMAIN][entry.entry_id], hass.data[DOMAIN] + ) + ) + + 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/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py new file mode 100644 index 00000000000..0f7cffcff4b --- /dev/null +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for NFAndroidTV integration.""" +from __future__ import annotations + +import logging + +from notifications_android_tv.notifications import ConnectError, Notifications +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NFAndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NFAndroidTV.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + host = user_input[CONF_HOST] + name = user_input[CONF_NAME] + + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured() + error = await self._async_try_connect(host) + if error is None: + return self.async_create_entry( + title=name, + data={CONF_HOST: host, CONF_NAME: name}, + ) + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == import_config[CONF_HOST]: + _LOGGER.warning( + "Already configured. This yaml configuration has already been imported. Please remove it" + ) + return self.async_abort(reason="already_configured") + if CONF_NAME not in import_config: + import_config[CONF_NAME] = f"{DEFAULT_NAME} {import_config[CONF_HOST]}" + + return await self.async_step_user(import_config) + + async def _async_try_connect(self, host): + """Try connecting to Android TV / Fire TV.""" + try: + await self.hass.async_add_executor_job(Notifications, host) + except ConnectError: + _LOGGER.error("Error connecting to device at %s", host) + return "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + return diff --git a/homeassistant/components/nfandroidtv/const.py b/homeassistant/components/nfandroidtv/const.py new file mode 100644 index 00000000000..332c1754771 --- /dev/null +++ b/homeassistant/components/nfandroidtv/const.py @@ -0,0 +1,28 @@ +"""Constants for the NFAndroidTV integration.""" +DOMAIN: str = "nfandroidtv" +CONF_DURATION = "duration" +CONF_FONTSIZE = "fontsize" +CONF_POSITION = "position" +CONF_TRANSPARENCY = "transparency" +CONF_COLOR = "color" +CONF_INTERRUPT = "interrupt" + +DEFAULT_NAME = "Android TV / Fire TV" +DEFAULT_TIMEOUT = 5 + +ATTR_DURATION = "duration" +ATTR_FONTSIZE = "fontsize" +ATTR_POSITION = "position" +ATTR_TRANSPARENCY = "transparency" +ATTR_COLOR = "color" +ATTR_BKGCOLOR = "bkgcolor" +ATTR_INTERRUPT = "interrupt" +ATTR_FILE = "file" +# Attributes contained in file +ATTR_FILE_URL = "url" +ATTR_FILE_PATH = "path" +ATTR_FILE_USERNAME = "username" +ATTR_FILE_PASSWORD = "password" +ATTR_FILE_AUTH = "auth" +# Any other value or absence of 'auth' lead to basic authentication being used +ATTR_FILE_AUTH_DIGEST = "digest" diff --git a/homeassistant/components/nfandroidtv/manifest.json b/homeassistant/components/nfandroidtv/manifest.json index 6f29d4d410e..5516f144fd4 100644 --- a/homeassistant/components/nfandroidtv/manifest.json +++ b/homeassistant/components/nfandroidtv/manifest.json @@ -1,7 +1,9 @@ { "domain": "nfandroidtv", - "name": "Notifications for Android TV / FireTV", + "name": "Notifications for Android TV / Fire TV", "documentation": "https://www.home-assistant.io/integrations/nfandroidtv", - "codeowners": [], + "requirements": ["notifications-android-tv==0.1.2"], + "codeowners": ["@tkdrob"], + "config_flow": true, "iot_class": "local_push" } diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index ad2f3fb3706..c2a42760aec 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -1,8 +1,7 @@ """Notifications for Android TV notification service.""" -import base64 -import io import logging +from notifications_android_tv import Notifications import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol @@ -14,115 +13,69 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_HOST, CONF_TIMEOUT, HTTP_OK, PERCENTAGE +from homeassistant.const import ATTR_ICON, CONF_HOST, CONF_TIMEOUT +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from .const import ( + ATTR_COLOR, + ATTR_DURATION, + ATTR_FILE, + ATTR_FILE_AUTH, + ATTR_FILE_AUTH_DIGEST, + ATTR_FILE_PASSWORD, + ATTR_FILE_PATH, + ATTR_FILE_URL, + ATTR_FILE_USERNAME, + ATTR_FONTSIZE, + ATTR_INTERRUPT, + ATTR_POSITION, + ATTR_TRANSPARENCY, + CONF_COLOR, + CONF_DURATION, + CONF_FONTSIZE, + CONF_INTERRUPT, + CONF_POSITION, + CONF_TRANSPARENCY, + DEFAULT_TIMEOUT, +) + _LOGGER = logging.getLogger(__name__) -CONF_DURATION = "duration" -CONF_FONTSIZE = "fontsize" -CONF_POSITION = "position" -CONF_TRANSPARENCY = "transparency" -CONF_COLOR = "color" -CONF_INTERRUPT = "interrupt" - -DEFAULT_DURATION = 5 -DEFAULT_FONTSIZE = "medium" -DEFAULT_POSITION = "bottom-right" -DEFAULT_TRANSPARENCY = "default" -DEFAULT_COLOR = "grey" -DEFAULT_INTERRUPT = False -DEFAULT_TIMEOUT = 5 -DEFAULT_ICON = ( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApo" - "cMXEAAAAASUVORK5CYII=" -) - -ATTR_DURATION = "duration" -ATTR_FONTSIZE = "fontsize" -ATTR_POSITION = "position" -ATTR_TRANSPARENCY = "transparency" -ATTR_COLOR = "color" -ATTR_BKGCOLOR = "bkgcolor" -ATTR_INTERRUPT = "interrupt" -ATTR_IMAGE = "filename2" -ATTR_FILE = "file" -# Attributes contained in file -ATTR_FILE_URL = "url" -ATTR_FILE_PATH = "path" -ATTR_FILE_USERNAME = "username" -ATTR_FILE_PASSWORD = "password" -ATTR_FILE_AUTH = "auth" -# Any other value or absence of 'auth' lead to basic authentication being used -ATTR_FILE_AUTH_DIGEST = "digest" - -FONTSIZES = {"small": 1, "medium": 0, "large": 2, "max": 3} - -POSITIONS = { - "bottom-right": 0, - "bottom-left": 1, - "top-right": 2, - "top-left": 3, - "center": 4, -} - -TRANSPARENCIES = { - "default": 0, - f"0{PERCENTAGE}": 1, - f"25{PERCENTAGE}": 2, - f"50{PERCENTAGE}": 3, - f"75{PERCENTAGE}": 4, - f"100{PERCENTAGE}": 5, -} - -COLORS = { - "grey": "#607d8b", - "black": "#000000", - "indigo": "#303F9F", - "green": "#4CAF50", - "red": "#F44336", - "cyan": "#00BCD4", - "teal": "#009688", - "amber": "#FFC107", - "pink": "#E91E63", -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Coerce(int), - vol.Optional(CONF_FONTSIZE, default=DEFAULT_FONTSIZE): vol.In(FONTSIZES.keys()), - vol.Optional(CONF_POSITION, default=DEFAULT_POSITION): vol.In(POSITIONS.keys()), - vol.Optional(CONF_TRANSPARENCY, default=DEFAULT_TRANSPARENCY): vol.In( - TRANSPARENCIES.keys() +# Deprecated in Home Assistant 2021.8 +PLATFORM_SCHEMA = cv.deprecated( + vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DURATION): vol.Coerce(int), + vol.Optional(CONF_FONTSIZE): vol.In(Notifications.FONTSIZES.keys()), + vol.Optional(CONF_POSITION): vol.In(Notifications.POSITIONS.keys()), + vol.Optional(CONF_TRANSPARENCY): vol.In( + Notifications.TRANSPARENCIES.keys() + ), + vol.Optional(CONF_COLOR): vol.In(Notifications.BKG_COLORS.keys()), + vol.Optional(CONF_TIMEOUT): vol.Coerce(int), + vol.Optional(CONF_INTERRUPT): cv.boolean, + } ), - vol.Optional(CONF_COLOR, default=DEFAULT_COLOR): vol.In(COLORS.keys()), - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), - vol.Optional(CONF_INTERRUPT, default=DEFAULT_INTERRUPT): cv.boolean, - } + ) ) -def get_service(hass, config, discovery_info=None): - """Get the Notifications for Android TV notification service.""" - remoteip = config.get(CONF_HOST) - duration = config.get(CONF_DURATION) - fontsize = config.get(CONF_FONTSIZE) - position = config.get(CONF_POSITION) - transparency = config.get(CONF_TRANSPARENCY) - color = config.get(CONF_COLOR) - interrupt = config.get(CONF_INTERRUPT) - timeout = config.get(CONF_TIMEOUT) - +async def async_get_service(hass: HomeAssistant, config, discovery_info=None): + """Get the NFAndroidTV notification service.""" + if discovery_info is not None: + notify = await hass.async_add_executor_job( + Notifications, discovery_info[CONF_HOST] + ) + return NFAndroidTVNotificationService( + notify, + hass.config.is_allowed_path, + ) + notify = await hass.async_add_executor_job(Notifications, config.get(CONF_HOST)) return NFAndroidTVNotificationService( - remoteip, - duration, - fontsize, - position, - transparency, - color, - interrupt, - timeout, + notify, hass.config.is_allowed_path, ) @@ -132,116 +85,98 @@ class NFAndroidTVNotificationService(BaseNotificationService): def __init__( self, - remoteip, - duration, - fontsize, - position, - transparency, - color, - interrupt, - timeout, + notify: Notifications, is_allowed_path, ): """Initialize the service.""" - self._target = f"http://{remoteip}:7676" - self._default_duration = duration - self._default_fontsize = fontsize - self._default_position = position - self._default_transparency = transparency - self._default_color = color - self._default_interrupt = interrupt - self._timeout = timeout - self._icon_file = io.BytesIO(base64.b64decode(DEFAULT_ICON)) + self.notify = notify self.is_allowed_path = is_allowed_path def send_message(self, message="", **kwargs): """Send a message to a Android TV device.""" - _LOGGER.debug("Sending notification to: %s", self._target) - - payload = { - "filename": ( - "icon.png", - self._icon_file, - "application/octet-stream", - {"Expires": "0"}, - ), - "type": "0", - "title": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - "msg": message, - "duration": "%i" % self._default_duration, - "fontsize": "%i" % FONTSIZES.get(self._default_fontsize), - "position": "%i" % POSITIONS.get(self._default_position), - "bkgcolor": "%s" % COLORS.get(self._default_color), - "transparency": "%i" % TRANSPARENCIES.get(self._default_transparency), - "offset": "0", - "app": ATTR_TITLE_DEFAULT, - "force": "true", - "interrupt": "%i" % self._default_interrupt, - } - data = kwargs.get(ATTR_DATA) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + duration = None + fontsize = None + position = None + transparency = None + bkgcolor = None + interrupt = None + icon = None + image_file = None if data: if ATTR_DURATION in data: - duration = data.get(ATTR_DURATION) try: - payload[ATTR_DURATION] = "%i" % int(duration) + duration = int(data.get(ATTR_DURATION)) except ValueError: - _LOGGER.warning("Invalid duration-value: %s", str(duration)) + _LOGGER.warning( + "Invalid duration-value: %s", str(data.get(ATTR_DURATION)) + ) if ATTR_FONTSIZE in data: - fontsize = data.get(ATTR_FONTSIZE) - if fontsize in FONTSIZES: - payload[ATTR_FONTSIZE] = "%i" % FONTSIZES.get(fontsize) + if data.get(ATTR_FONTSIZE) in Notifications.FONTSIZES: + fontsize = data.get(ATTR_FONTSIZE) else: - _LOGGER.warning("Invalid fontsize-value: %s", str(fontsize)) + _LOGGER.warning( + "Invalid fontsize-value: %s", str(data.get(ATTR_FONTSIZE)) + ) if ATTR_POSITION in data: - position = data.get(ATTR_POSITION) - if position in POSITIONS: - payload[ATTR_POSITION] = "%i" % POSITIONS.get(position) + if data.get(ATTR_POSITION) in Notifications.POSITIONS: + position = data.get(ATTR_POSITION) else: - _LOGGER.warning("Invalid position-value: %s", str(position)) + _LOGGER.warning( + "Invalid position-value: %s", str(data.get(ATTR_POSITION)) + ) if ATTR_TRANSPARENCY in data: - transparency = data.get(ATTR_TRANSPARENCY) - if transparency in TRANSPARENCIES: - payload[ATTR_TRANSPARENCY] = "%i" % TRANSPARENCIES.get(transparency) + if data.get(ATTR_TRANSPARENCY) in Notifications.TRANSPARENCIES: + transparency = data.get(ATTR_TRANSPARENCY) else: - _LOGGER.warning("Invalid transparency-value: %s", str(transparency)) + _LOGGER.warning( + "Invalid transparency-value: %s", + str(data.get(ATTR_TRANSPARENCY)), + ) if ATTR_COLOR in data: - color = data.get(ATTR_COLOR) - if color in COLORS: - payload[ATTR_BKGCOLOR] = "%s" % COLORS.get(color) + if data.get(ATTR_COLOR) in Notifications.BKG_COLORS: + bkgcolor = data.get(ATTR_COLOR) else: - _LOGGER.warning("Invalid color-value: %s", str(color)) + _LOGGER.warning( + "Invalid color-value: %s", str(data.get(ATTR_COLOR)) + ) if ATTR_INTERRUPT in data: - interrupt = data.get(ATTR_INTERRUPT) try: - payload[ATTR_INTERRUPT] = "%i" % cv.boolean(interrupt) + interrupt = cv.boolean(data.get(ATTR_INTERRUPT)) except vol.Invalid: - _LOGGER.warning("Invalid interrupt-value: %s", str(interrupt)) + _LOGGER.warning( + "Invalid interrupt-value: %s", str(data.get(ATTR_INTERRUPT)) + ) filedata = data.get(ATTR_FILE) if data else None if filedata is not None: - # Load from file or URL - file_as_bytes = self.load_file( + if ATTR_ICON in filedata: + icon = self.load_file( + url=filedata.get(ATTR_ICON), + local_path=filedata.get(ATTR_FILE_PATH), + username=filedata.get(ATTR_FILE_USERNAME), + password=filedata.get(ATTR_FILE_PASSWORD), + auth=filedata.get(ATTR_FILE_AUTH), + ) + image_file = self.load_file( url=filedata.get(ATTR_FILE_URL), local_path=filedata.get(ATTR_FILE_PATH), username=filedata.get(ATTR_FILE_USERNAME), password=filedata.get(ATTR_FILE_PASSWORD), auth=filedata.get(ATTR_FILE_AUTH), ) - if file_as_bytes: - payload[ATTR_IMAGE] = ( - "image", - file_as_bytes, - "application/octet-stream", - {"Expires": "0"}, - ) - - try: - _LOGGER.debug("Payload: %s", str(payload)) - response = requests.post(self._target, files=payload, timeout=self._timeout) - if response.status_code != HTTP_OK: - _LOGGER.error("Error sending message: %s", str(response)) - except requests.exceptions.ConnectionError as err: - _LOGGER.error("Error communicating with %s: %s", self._target, str(err)) + self.notify.send( + message, + title=title, + duration=duration, + fontsize=fontsize, + position=position, + bkgcolor=bkgcolor, + transparency=transparency, + interrupt=interrupt, + icon=icon, + image_file=image_file, + ) def load_file( self, url=None, local_path=None, username=None, password=None, auth=None @@ -266,7 +201,8 @@ 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 + with open(local_path, "rb") as path_handle: + return path_handle _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/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json new file mode 100644 index 00000000000..5940f86a406 --- /dev/null +++ b/homeassistant/components/nfandroidtv/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Notifications for Android TV / Fire TV", + "description": "This integration requires the Notifications for Android TV app.\n\nFor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nYou should set up either DHCP reservation on your router (refer to your router's user manual) or a static IP address on the device. If not, the device will eventually become unavailable.", + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/nfandroidtv/translations/en.json b/homeassistant/components/nfandroidtv/translations/en.json new file mode 100644 index 00000000000..22d014c1ffa --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "This integration requires the Notifications for Android TV app.\n\nFor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nYou should set up either DHCP reservation on your router (refer to your router's user manual) or a static IP address on the device. If not, the device will eventually become unavailable.", + "title": "Notifications for Android TV / Fire TV" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1321c01b27d..943ca9cda74 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -178,6 +178,7 @@ FLOWS = [ "nest", "netatmo", "nexia", + "nfandroidtv", "nightscout", "notion", "nuheat", diff --git a/requirements_all.txt b/requirements_all.txt index 0559e927aec..bb119c6a2c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1032,6 +1032,9 @@ niluclient==0.1.2 # homeassistant.components.noaa_tides noaa-coops==0.1.8 +# homeassistant.components.nfandroidtv +notifications-android-tv==0.1.2 + # homeassistant.components.notify_events notify-events==1.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80f25c59e31..22a017442eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -577,6 +577,9 @@ nettigo-air-monitor==1.0.0 # homeassistant.components.nexia nexia==0.9.10 +# homeassistant.components.nfandroidtv +notifications-android-tv==0.1.2 + # homeassistant.components.notify_events notify-events==1.0.4 diff --git a/tests/components/nfandroidtv/__init__.py b/tests/components/nfandroidtv/__init__.py new file mode 100644 index 00000000000..056e2b2bc71 --- /dev/null +++ b/tests/components/nfandroidtv/__init__.py @@ -0,0 +1,31 @@ +"""Tests for the NFAndroidTV integration.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.const import CONF_HOST, CONF_NAME + +HOST = "1.2.3.4" +NAME = "Android TV / Fire TV" + +CONF_DATA = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + +CONF_CONFIG_FLOW = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + + +async def _create_mocked_tv(raise_exception=False): + mocked_tv = AsyncMock() + mocked_tv.get_state = AsyncMock() + return mocked_tv + + +def _patch_config_flow_tv(mocked_tv): + return patch( + "homeassistant.components.nfandroidtv.config_flow.Notifications", + return_value=mocked_tv, + ) diff --git a/tests/components/nfandroidtv/test_config_flow.py b/tests/components/nfandroidtv/test_config_flow.py new file mode 100644 index 00000000000..b16b053c70f --- /dev/null +++ b/tests/components/nfandroidtv/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test NFAndroidTV config flow.""" +from unittest.mock import patch + +from notifications_android_tv.notifications import ConnectError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.nfandroidtv.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME + +from . import ( + CONF_CONFIG_FLOW, + CONF_DATA, + HOST, + NAME, + _create_mocked_tv, + _patch_config_flow_tv, +) + +from tests.common import MockConfigEntry + + +def _patch_setup(): + return patch( + "homeassistant.components.nfandroidtv.async_setup_entry", + return_value=True, + ) + + +async def test_flow_user(hass): + """Test user initialized flow.""" + mocked_tv = await _create_mocked_tv() + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_already_configured(hass): + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_CONFIG_FLOW, + unique_id=HOST, + ) + + entry.add_to_hass(hass) + + mocked_tv = await _create_mocked_tv() + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect(hass): + """Test user initialized flow with unreachable server.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv) as tvmock: + tvmock.side_effect = ConnectError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_user_unknown_error(hass): + """Test user initialized flow with unreachable server.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv) as tvmock: + tvmock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_import(hass): + """Test an import flow.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_CONFIG_FLOW, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == CONF_DATA + + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_CONFIG_FLOW, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import_missing_optional(hass): + """Test an import flow with missing options.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: HOST}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_HOST: HOST, CONF_NAME: f"{DEFAULT_NAME} {HOST}"} From 7fef87691af5c4231bc37e5d935a7d9ac7fdd8a8 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 08:07:26 -0400 Subject: [PATCH 054/112] Use entity class attributes for airvisual (#52503) * Use entity class attributes for airvisual * fix * rework * tweaks * finish * remove overriden available attribute * rework --- homeassistant/components/airvisual/sensor.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 796607f8215..d4b988a0ddc 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -266,9 +266,12 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): self._attr_device_class = device_class self._attr_icon = icon + self._attr_name = ( + 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._kind = kind - self._name = name @property def device_info(self): @@ -284,17 +287,6 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): ), } - @property - def name(self): - """Return the name.""" - node_name = self.coordinator.data["settings"]["node_name"] - return f"{node_name} Node/Pro: {self._name}" - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self.coordinator.data['serial_number']}_{self._kind}" - @callback def update_from_latest_data(self): """Update the entity from the latest data.""" From 668437741a582ce5464c8bd6a9b592dcb2cb6f83 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 08:09:54 -0400 Subject: [PATCH 055/112] Use entity class attributes for Bmw connected drive (#53054) * Use entity class attributes for bmw_connected_driv * forgot the icon --- .../bmw_connected_drive/__init__.py | 26 ++--- .../bmw_connected_drive/binary_sensor.py | 101 ++++++----------- .../bmw_connected_drive/device_tracker.py | 27 +---- .../components/bmw_connected_drive/lock.py | 63 +++-------- .../components/bmw_connected_drive/sensor.py | 103 ++++++------------ 5 files changed, 99 insertions(+), 221 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 599892d6a03..3bd2365f88e 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry, discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_utc_time_change from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -317,6 +317,8 @@ class BMWConnectedDriveAccount: class BMWConnectedDriveBaseEntity(Entity): """Common base for BMW entities.""" + _attr_should_poll = False + def __init__(self, account, vehicle): """Initialize sensor.""" self._account = account @@ -326,15 +328,11 @@ class BMWConnectedDriveBaseEntity(Entity): "vin": self._vehicle.vin, ATTR_ATTRIBUTION: ATTRIBUTION, } - - @property - def device_info(self) -> DeviceInfo: - """Return info for device registry.""" - return { - "identifiers": {(DOMAIN, self._vehicle.vin)}, - "name": f'{self._vehicle.attributes.get("brand")} {self._vehicle.name}', - "model": self._vehicle.name, - "manufacturer": self._vehicle.attributes.get("brand"), + self._attr_device_info = { + "identifiers": {(DOMAIN, vehicle.vin)}, + "name": f'{vehicle.attributes.get("brand")} {vehicle.name}', + "model": vehicle.name, + "manufacturer": vehicle.attributes.get("brand"), } @property @@ -342,14 +340,6 @@ class BMWConnectedDriveBaseEntity(Entity): """Return the state attributes of the sensor.""" return self._attrs - @property - def should_poll(self): - """Do not poll this class. - - Updates are triggered from BMWConnectedDriveAccount. - """ - return False - 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 bebb55bbde0..d7f0d150193 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -76,41 +76,45 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): super().__init__(account, vehicle) self._attribute = attribute - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attr_name = f"{vehicle.name} {attribute}" + self._attr_unique_id = f"{vehicle.vin}-{attribute}" self._sensor_name = sensor_name - self._device_class = device_class - self._icon = icon - self._state = None + self._attr_device_class = device_class + self._attr_icon = icon - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return self._unique_id + def update(self): + """Read new state data from the library.""" + vehicle_state = self._vehicle.state - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name + # 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" - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes of the binary sensor.""" vehicle_state = self._vehicle.state result = self._attrs.copy() @@ -144,40 +148,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): elif self._attribute == "connection_status": result["connection_status"] = vehicle_state.connection_status - return sorted(result.items()) - - def update(self): - """Read new state data from the library.""" - vehicle_state = self._vehicle.state - - # 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._state = not vehicle_state.all_lids_closed - if self._attribute == "windows": - self._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._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._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._state = not vehicle_state.are_all_cbs_ok - if self._attribute == "check_control_messages": - self._state = vehicle_state.has_check_control_messages - # device class power: On means power detected, Off means no power - if self._attribute == "charging_status": - self._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._state = vehicle_state.connection_status == "CONNECTED" + self._attr_extra_state_attributes = sorted(result.items()) 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 25adf6cb09f..62b2ed9b9d9 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -29,15 +29,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): """BMW Connected Drive device tracker.""" + _attr_force_update = False + _attr_icon = "mdi:car" + def __init__(self, account, vehicle): """Initialize the Tracker.""" super().__init__(account, vehicle) - self._unique_id = vehicle.vin + self._attr_unique_id = vehicle.vin self._location = ( vehicle.state.gps_position if vehicle.state.gps_position else (None, None) ) - self._name = vehicle.name + self._attr_name = vehicle.name @property def latitude(self): @@ -49,31 +52,11 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): """Return longitude value of the device.""" return self._location[1] if self._location else None - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID.""" - return self._unique_id - @property def source_type(self): """Return the source type, eg gps or router, of the device.""" return SOURCE_TYPE_GPS - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return "mdi:car" - - @property - def force_update(self): - """All updates do not need to be written to the state machine.""" - return False - def update(self): """Update state of the decvice tracker.""" self._location = ( diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 97c9be7216b..3d27cf833b6 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -4,7 +4,6 @@ import logging from bimmer_connected.state import LockState from homeassistant.components.lock import LockEntity -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity from .const import CONF_ACCOUNT, DATA_ENTRIES @@ -33,50 +32,17 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): super().__init__(account, vehicle) self._attribute = attribute - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attr_name = f"{vehicle.name} {attribute}" + self._attr_unique_id = f"{vehicle.vin}-{attribute}" self._sensor_name = sensor_name - self._state = None - self.door_lock_state_available = ( - DOOR_LOCK_STATE in self._vehicle.available_attributes - ) - - @property - def unique_id(self): - """Return the unique ID of the lock.""" - return self._unique_id - - @property - def name(self): - """Return the name of the lock.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes of the lock.""" - vehicle_state = self._vehicle.state - result = self._attrs.copy() - - if self.door_lock_state_available: - result["door_lock_state"] = vehicle_state.door_lock_state.value - result["last_update_reason"] = vehicle_state.last_update_reason - return result - - @property - def is_locked(self): - """Return true if lock is locked.""" - if self.door_lock_state_available: - result = self._state == STATE_LOCKED - else: - result = None - return result + self.door_lock_state_available = DOOR_LOCK_STATE in vehicle.available_attributes def lock(self, **kwargs): """Lock the car.""" _LOGGER.debug("%s: locking doors", self._vehicle.name) # Optimistic state set here because it takes some time before the # update callback response - self._state = STATE_LOCKED + self._attr_is_locked = True self.schedule_update_ha_state() self._vehicle.remote_services.trigger_remote_door_lock() @@ -85,18 +51,23 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): _LOGGER.debug("%s: unlocking doors", self._vehicle.name) # Optimistic state set here because it takes some time before the # update callback response - self._state = STATE_UNLOCKED + self._attr_is_locked = False self.schedule_update_ha_state() self._vehicle.remote_services.trigger_remote_door_unlock() def update(self): """Update state of the lock.""" _LOGGER.debug("%s: updating data for %s", self._vehicle.name, self._attribute) - vehicle_state = self._vehicle.state + if self._vehicle.state.door_lock_state in [LockState.LOCKED, LockState.SECURED]: + self._attr_is_locked = True + else: + self._attr_is_locked = False + if not self.door_lock_state_available: + self._attr_is_locked = None - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - self._state = ( - STATE_LOCKED - if vehicle_state.door_lock_state in [LockState.LOCKED, LockState.SECURED] - else STATE_UNLOCKED - ) + vehicle_state = self._vehicle.state + result = self._attrs.copy() + if self.door_lock_state_available: + result["door_lock_state"] = vehicle_state.door_lock_state.value + result["last_update_reason"] = vehicle_state.last_update_reason + self._attr_extra_state_attributes = result diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 8d44d1290dc..df899496339 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -503,94 +503,46 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): self._attribute = attribute self._service = service - self._state = None - if self._service: - self._name = ( - f"{self._vehicle.name} {self._service.lower()}_{self._attribute}" - ) - self._unique_id = ( - f"{self._vehicle.vin}-{self._service.lower()}-{self._attribute}" - ) + if service: + self._attr_name = f"{vehicle.name} {service.lower()}_{attribute}" + self._attr_unique_id = f"{vehicle.vin}-{service.lower()}-{attribute}" else: - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attr_name = f"{vehicle.name} {attribute}" + self._attr_unique_id = f"{vehicle.vin}-{attribute}" self._attribute_info = attribute_info - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - vehicle_state = self._vehicle.state - charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] - - if self._attribute == "charging_level_hv": - return icon_for_battery_level( - battery_level=vehicle_state.charging_level_hv, charging=charging_state - ) - icon = self._attribute_info.get(self._attribute, [None, None, None, None])[0] - return icon - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - enabled_default = self._attribute_info.get( - self._attribute, [None, None, None, True] + self._attr_entity_registry_enabled_default = attribute_info.get( + attribute, [None, None, None, True] )[3] - return enabled_default - - @property - def state(self): - """Return the state of the sensor. - - The return type of this call depends on the attribute that - is configured. - """ - return self._state - - @property - def device_class(self) -> str: - """Get the device class.""" - clss = self._attribute_info.get(self._attribute, [None, None, None, None])[1] - return clss - - @property - def unit_of_measurement(self) -> str: - """Get the unit of measurement.""" - unit = self._attribute_info.get(self._attribute, [None, None, None, None])[2] - return unit + self._attr_device_class = attribute_info.get( + attribute, [None, None, None, None] + )[1] + self._attr_unit_of_measurement = attribute_info.get( + attribute, [None, None, None, None] + )[2] def update(self) -> None: """Read new state data from the library.""" _LOGGER.debug("Updating %s", self._vehicle.name) vehicle_state = self._vehicle.state if self._attribute == "charging_status": - self._state = getattr(vehicle_state, self._attribute).value + self._attr_state = 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._state = round(value_converted) + self._attr_state = 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._state = round(value_converted) + self._attr_state = round(value_converted) elif self._service is None: - self._state = getattr(vehicle_state, self._attribute) + self._attr_state = 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._state = dt_util.parse_datetime(date_str).isoformat() + self._attr_state = dt_util.parse_datetime(date_str).isoformat() else: - self._state = getattr(vehicle_last_trip, self._attribute) + self._attr_state = getattr(vehicle_last_trip, self._attribute) elif self._service == SERVICE_ALL_TRIPS: vehicle_all_trips = self._vehicle.state.all_trips for attribute in ( @@ -603,10 +555,21 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): if self._attribute.startswith(f"{attribute}_"): attr = getattr(vehicle_all_trips, attribute) sub_attr = self._attribute.replace(f"{attribute}_", "") - self._state = getattr(attr, sub_attr) + self._attr_state = getattr(attr, sub_attr) return if self._attribute == "reset_date_utc": date_str = getattr(vehicle_all_trips, "reset_date") - self._state = dt_util.parse_datetime(date_str).isoformat() + self._attr_state = dt_util.parse_datetime(date_str).isoformat() else: - self._state = getattr(vehicle_all_trips, self._attribute) + self._attr_state = getattr(vehicle_all_trips, self._attribute) + + vehicle_state = self._vehicle.state + charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] + + if self._attribute == "charging_level_hv": + self._attr_icon = icon_for_battery_level( + battery_level=vehicle_state.charging_level_hv, charging=charging_state + ) + self._attr_icon = self._attribute_info.get( + self._attribute, [None, None, None, None] + )[0] From 0803b2aecd537b836338f4ff075afff242deceba Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 08:32:42 -0400 Subject: [PATCH 056/112] Use entity class attributes for arest (#52678) --- .../components/arest/binary_sensor.py | 28 +++------- homeassistant/components/arest/sensor.py | 53 ++++++------------- homeassistant/components/arest/switch.py | 44 +++++---------- 3 files changed, 36 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index 3cd9038f1a8..d59e6d0cccb 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -73,34 +73,18 @@ class ArestBinarySensor(BinarySensorEntity): def __init__(self, arest, resource, name, device_class, pin): """Initialize the aREST device.""" self.arest = arest - self._resource = resource - self._name = name - self._device_class = device_class - self._pin = pin + self._attr_name = name + self._attr_device_class = device_class - if self._pin is not None: - request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if pin is not None: + request = requests.get(f"{resource}/mode/{pin}/i", timeout=10) if request.status_code != HTTP_OK: - _LOGGER.error("Can't set mode of %s", self._resource) - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return bool(self.arest.data.get("state")) - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class + _LOGGER.error("Can't set mode of %s", resource) def update(self): """Get the latest data from aREST API.""" self.arest.update() + self._attr_is_on = bool(self.arest.data.get("state")) class ArestData: diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 061c15eafb0..7129b989f47 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -139,48 +139,27 @@ class ArestSensor(SensorEntity): ): """Initialize the sensor.""" self.arest = arest - self._resource = resource - self._name = f"{location.title()} {name.title()}" + self._attr_name = f"{location.title()} {name.title()}" self._variable = variable - self._pin = pin - self._state = None - self._unit_of_measurement = unit_of_measurement + self._attr_unit_of_measurement = unit_of_measurement self._renderer = renderer - if self._pin is not None: - request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if pin is not None: + request = requests.get(f"{resource}/mode/{pin}/i", timeout=10) if request.status_code != HTTP_OK: - _LOGGER.error("Can't set mode of %s", self._resource) - - @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 self._unit_of_measurement - - @property - def state(self): - """Return the state of the sensor.""" - values = self.arest.data - - if "error" in values: - return values["error"] - - value = self._renderer(values.get("value", values.get(self._variable, None))) - return value + _LOGGER.error("Can't set mode of %s", resource) def update(self): """Get the latest data from aREST API.""" self.arest.update() - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self.arest.available + self._attr_available = self.arest.available + values = self.arest.data + if "error" in values: + self._attr_state = values["error"] + else: + self._attr_state = self._renderer( + values.get("value", values.get(self._variable, None)) + ) class ArestData: @@ -191,7 +170,7 @@ class ArestData: self._resource = resource self._pin = pin self.data = {} - self.available = True + self._attr_available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -212,7 +191,7 @@ class ArestData: f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self.available = True + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self.available = False + self._attr_available = False diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index ddd6b51f76d..d20eb7a5f8d 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -86,24 +86,8 @@ class ArestSwitchBase(SwitchEntity): def __init__(self, resource, location, name): """Initialize the switch.""" self._resource = resource - self._name = f"{location.title()} {name.title()}" - self._state = None - self._available = True - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available + self._attr_name = f"{location.title()} {name.title()}" + self._attr_available = True class ArestSwitchFunction(ArestSwitchBase): @@ -134,7 +118,7 @@ class ArestSwitchFunction(ArestSwitchBase): ) if request.status_code == HTTP_OK: - self._state = True + self._attr_is_on = True else: _LOGGER.error("Can't turn on function %s at %s", self._func, self._resource) @@ -145,7 +129,7 @@ class ArestSwitchFunction(ArestSwitchBase): ) if request.status_code == HTTP_OK: - self._state = False + self._attr_is_on = False else: _LOGGER.error( "Can't turn off function %s at %s", self._func, self._resource @@ -155,11 +139,11 @@ class ArestSwitchFunction(ArestSwitchBase): """Get the latest data from aREST API and update the state.""" try: request = requests.get(f"{self._resource}/{self._func}", timeout=10) - self._state = request.json()["return_value"] != 0 - self._available = True + self._attr_is_on = request.json()["return_value"] != 0 + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) - self._available = False + self._attr_available = False class ArestSwitchPin(ArestSwitchBase): @@ -171,10 +155,10 @@ class ArestSwitchPin(ArestSwitchBase): self._pin = pin self.invert = invert - request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) + request = requests.get(f"{resource}/mode/{pin}/o", timeout=10) if request.status_code != HTTP_OK: _LOGGER.error("Can't set mode") - self._available = False + self._attr_available = False def turn_on(self, **kwargs): """Turn the device on.""" @@ -183,7 +167,7 @@ class ArestSwitchPin(ArestSwitchBase): f"{self._resource}/digital/{self._pin}/{turn_on_payload}", timeout=10 ) if request.status_code == HTTP_OK: - self._state = True + self._attr_is_on = True else: _LOGGER.error("Can't turn on pin %s at %s", self._pin, self._resource) @@ -194,7 +178,7 @@ class ArestSwitchPin(ArestSwitchBase): f"{self._resource}/digital/{self._pin}/{turn_off_payload}", timeout=10 ) if request.status_code == HTTP_OK: - self._state = False + self._attr_is_on = False else: _LOGGER.error("Can't turn off pin %s at %s", self._pin, self._resource) @@ -203,8 +187,8 @@ class ArestSwitchPin(ArestSwitchBase): try: request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) status_value = int(self.invert) - self._state = request.json()["return_value"] != status_value - self._available = True + self._attr_is_on = request.json()["return_value"] != status_value + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) - self._available = False + self._attr_available = False From 800f7fe3a5cd94e405e87e834cf99dcda0fc3948 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 09:27:52 -0400 Subject: [PATCH 057/112] Use entity class attributes for Broadlink (#53058) * Clanup broadlink * rework * tweak * fix using wrong attribute * tweak * revert device info --- homeassistant/components/broadlink/entity.py | 3 +- homeassistant/components/broadlink/remote.py | 4 +- homeassistant/components/broadlink/sensor.py | 12 ++-- homeassistant/components/broadlink/switch.py | 61 +++++++++----------- 4 files changed, 36 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py index 850611b391f..fc5d22302a6 100644 --- a/homeassistant/components/broadlink/entity.py +++ b/homeassistant/components/broadlink/entity.py @@ -1,11 +1,12 @@ """Broadlink entities.""" from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity from .const import DOMAIN -class BroadlinkEntity: +class BroadlinkEntity(Entity): """Representation of a Broadlink entity.""" _attr_should_poll = False diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index b9dd34d22d8..3bb85ab9d85 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -127,10 +127,10 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): self._flags = defaultdict(int) self._lock = asyncio.Lock() - self._attr_name = f"{self._device.name} Remote" + self._attr_name = f"{device.name} Remote" self._attr_is_on = True self._attr_supported_features = SUPPORT_LEARN_COMMAND | SUPPORT_DELETE_COMMAND - self._attr_unique_id = self._device.unique_id + self._attr_unique_id = device.unique_id def _extract_codes(self, commands, device=None): """Extract a list of codes. diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 851668fdeff..044486a4a67 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -77,14 +77,12 @@ class BroadlinkSensor(BroadlinkEntity, SensorEntity): self._coordinator = device.update_manager.coordinator self._monitored_condition = monitored_condition - self._attr_device_class = SENSOR_TYPES[self._monitored_condition][2] - self._attr_name = ( - f"{self._device.name} {SENSOR_TYPES[self._monitored_condition][0]}" - ) - self._attr_state_class = SENSOR_TYPES[self._monitored_condition][3] + 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_unique_id = f"{self._device.unique_id}-{self._monitored_condition}" - self._attr_unit_of_measurement = SENSOR_TYPES[self._monitored_condition][1] + self._attr_unique_id = f"{device.unique_id}-{monitored_condition}" + self._attr_unit_of_measurement = SENSOR_TYPES[monitored_condition][1] @callback def update_data(self): diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 1576c8b8418..9f434102b17 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -135,22 +135,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): """Representation of a Broadlink switch.""" + _attr_assumed_state = True + _attr_device_class = DEVICE_CLASS_SWITCH + def __init__(self, device, command_on, command_off): """Initialize the switch.""" super().__init__(device) self._command_on = command_on self._command_off = command_off self._coordinator = device.update_manager.coordinator - self._state = None self._attr_assumed_state = True self._attr_device_class = DEVICE_CLASS_SWITCH - self._attr_name = f"{self._device.name} Switch" - - @property - def is_on(self): - """Return True if the switch is on.""" - return self._state + self._attr_name = f"{device.name} Switch" + self._attr_unique_id = device.unique_id @callback def update_data(self): @@ -159,9 +157,8 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): async def async_added_to_hass(self): """Call when the switch is added to hass.""" - if self._state is None: - state = await self.async_get_last_state() - self._state = state is not None and state.state == STATE_ON + state = await self.async_get_last_state() + self._attr_is_on = state is not None and state.state == STATE_ON self.async_on_remove(self._coordinator.async_add_listener(self.update_data)) async def async_update(self): @@ -171,13 +168,13 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): async def async_turn_on(self, **kwargs): """Turn on the switch.""" if await self._async_send_packet(self._command_on): - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn off the switch.""" if await self._async_send_packet(self._command_off): - self._state = False + self._attr_is_on = False self.async_write_ha_state() @abstractmethod @@ -229,46 +226,41 @@ class BroadlinkSP1Switch(BroadlinkSwitch): class BroadlinkSP2Switch(BroadlinkSP1Switch): """Representation of a Broadlink SP2 switch.""" + _attr_assumed_state = False + def __init__(self, device, *args, **kwargs): """Initialize the switch.""" super().__init__(device, *args, **kwargs) - self._state = self._coordinator.data["pwr"] - self._load_power = self._coordinator.data.get("power") - - self._attr_assumed_state = False - - @property - def current_power_w(self): - """Return the current power usage in Watt.""" - return self._load_power + self._attr_is_on = self._coordinator.data["pwr"] + self._attr_current_power_w = self._coordinator.data.get("power") @callback def update_data(self): """Update data.""" if self._coordinator.last_update_success: - self._state = self._coordinator.data["pwr"] - self._load_power = self._coordinator.data.get("power") + self._attr_is_on = self._coordinator.data["pwr"] + self._attr_current_power_w = self._coordinator.data.get("power") self.async_write_ha_state() class BroadlinkMP1Slot(BroadlinkSwitch): """Representation of a Broadlink MP1 slot.""" + _attr_assumed_state = False + def __init__(self, device, slot): """Initialize the switch.""" super().__init__(device, 1, 0) self._slot = slot - self._state = self._coordinator.data[f"s{slot}"] - - self._attr_name = f"{self._device.name} S{self._slot}" - self._attr_unique_id = f"{self._device.unique_id}-s{self._slot}" - self._attr_assumed_state = False + self._attr_is_on = self._coordinator.data[f"s{slot}"] + self._attr_name = f"{device.name} S{slot}" + self._attr_unique_id = f"{device.unique_id}-s{slot}" @callback def update_data(self): """Update data.""" if self._coordinator.last_update_success: - self._state = self._coordinator.data[f"s{self._slot}"] + self._attr_is_on = self._coordinator.data[f"s{self._slot}"] self.async_write_ha_state() async def _async_send_packet(self, packet): @@ -286,22 +278,23 @@ class BroadlinkMP1Slot(BroadlinkSwitch): class BroadlinkBG1Slot(BroadlinkSwitch): """Representation of a Broadlink BG1 slot.""" + _attr_assumed_state = False + def __init__(self, device, slot): """Initialize the switch.""" super().__init__(device, 1, 0) self._slot = slot - self._state = self._coordinator.data[f"pwr{slot}"] + self._attr_is_on = self._coordinator.data[f"pwr{slot}"] - self._attr_name = f"{self._device.name} S{self._slot}" + self._attr_name = f"{device.name} S{slot}" self._attr_device_class = DEVICE_CLASS_OUTLET - self._attr_unique_id = f"{self._device.unique_id}-s{self._slot}" - self._attr_assumed_state = False + self._attr_unique_id = f"{device.unique_id}-s{slot}" @callback def update_data(self): """Update data.""" if self._coordinator.last_update_success: - self._state = self._coordinator.data[f"pwr{self._slot}"] + self._attr_is_on = self._coordinator.data[f"pwr{self._slot}"] self.async_write_ha_state() async def _async_send_packet(self, packet): From f128bc9ef8cd991a19e5e0f823c7dc6971b27677 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 21 Jul 2021 18:16:27 +0200 Subject: [PATCH 058/112] Add reauth flow to Synology DSM (#53204) --- .../components/synology_dsm/__init__.py | 35 +++++++++++- .../components/synology_dsm/config_flow.py | 55 +++++++++++++++++-- .../components/synology_dsm/const.py | 2 + .../components/synology_dsm/strings.json | 11 +++- .../synology_dsm/translations/de.json | 11 +++- .../synology_dsm/translations/en.json | 11 +++- .../synology_dsm/test_config_flow.py | 52 +++++++++++++++++- tests/components/synology_dsm/test_init.py | 28 ++++++++++ 8 files changed, 195 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 0bbf5febbc5..9ec56b898ca 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -18,11 +18,15 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, SynologyDSMLoginFailedException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, SynologyDSMRequestException, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_HOST, @@ -64,6 +68,8 @@ from .const import ( ENTITY_ICON, ENTITY_NAME, ENTITY_UNIT, + EXCEPTION_DETAILS, + EXCEPTION_UNKNOWN, PLATFORMS, SERVICE_REBOOT, SERVICE_SHUTDOWN, @@ -181,6 +187,33 @@ async def async_setup_entry( # noqa: C901 api = SynoApi(hass, entry) try: await api.async_setup() + except ( + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, + ) as err: + if err.args[0] and isinstance(err.args[0], dict): + # pylint: disable=no-member + details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) + else: + details = EXCEPTION_UNKNOWN + _LOGGER.debug( + "Reauthentication for DSM '%s' needed - reason: %s", + entry.unique_id, + details, + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": {**entry.data}, + EXCEPTION_DETAILS: details, + }, + ) + ) + return False except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: _LOGGER.debug( "Unable to connect to DSM '%s' during setup: %s", entry.unique_id, err diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 5f11f158cec..97f9e4343fa 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -46,6 +46,7 @@ from .const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, + EXCEPTION_DETAILS, ) _LOGGER = logging.getLogger(__name__) @@ -57,6 +58,15 @@ def _discovery_schema_with_defaults(discovery_info: DiscoveryInfoType) -> vol.Sc return vol.Schema(_ordered_shared_schema(discovery_info)) +def _reauth_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, + vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")): str, + } + ) + + def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: user_schema = { vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, @@ -100,6 +110,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize the synology_dsm config flow.""" self.saved_user_input: dict[str, Any] = {} self.discovered_conf: dict[str, Any] = {} + self.reauth_conf: dict[str, Any] = {} + self.reauth_reason: str | None = None async def _show_setup_form( self, @@ -110,10 +122,18 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if not user_input: user_input = {} + description_placeholders = {} + if self.discovered_conf: user_input.update(self.discovered_conf) step_id = "link" data_schema = _discovery_schema_with_defaults(user_input) + description_placeholders = self.discovered_conf + elif self.reauth_conf: + user_input.update(self.reauth_conf) + step_id = "reauth" + data_schema = _reauth_schema_with_defaults(user_input) + description_placeholders = {EXCEPTION_DETAILS: self.reauth_reason} else: step_id = "user" data_schema = _user_schema_with_defaults(user_input) @@ -122,7 +142,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=data_schema, errors=errors or {}, - description_placeholders=self.discovered_conf or {}, + description_placeholders=description_placeholders, ) async def async_step_user( @@ -137,6 +157,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if self.discovered_conf: user_input.update(self.discovered_conf) + if self.reauth_conf: + self.reauth_conf.update( + { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + user_input.update(self.reauth_conf) + host = user_input[CONF_HOST] port = user_input.get(CONF_PORT) username = user_input[CONF_USERNAME] @@ -181,10 +210,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self._show_setup_form(user_input, errors) # unique_id should be serial for services purpose - await self.async_set_unique_id(serial, raise_on_progress=False) - - # Check if already configured - self._abort_if_unique_id_configured() + existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) config_data = { CONF_HOST: host, @@ -202,6 +228,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input.get(CONF_VOLUMES): config_data[CONF_VOLUMES] = user_input[CONF_VOLUMES] + if existing_entry and self.reauth_conf: + self.hass.config_entries.async_update_entry( + existing_entry, data=config_data + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + if existing_entry: + return self.async_abort(reason="already_configured") + return self.async_create_entry(title=host, data=config_data) async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: @@ -227,6 +262,16 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_conf = self.context.get("data", {}) + self.reauth_reason = self.context.get(EXCEPTION_DETAILS) + if user_input is None: + return await self.async_step_user() + return await self.async_step_user(user_input) + async def async_step_link(self, user_input: dict[str, Any]) -> FlowResult: """Link a config entry from discovery.""" return await self.async_step_user(user_input) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 334832ddf2b..e8b919f09d5 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -37,6 +37,8 @@ COORDINATOR_CAMERAS = "coordinator_cameras" COORDINATOR_CENTRAL = "coordinator_central" COORDINATOR_SWITCHES = "coordinator_switches" SYSTEM_LOADED = "system_loaded" +EXCEPTION_DETAILS = "details" +EXCEPTION_UNKNOWN = "unknown" # Entry keys SYNO_API = "syno_api" diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 1464b8a6a06..6baaaaef9f6 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -29,6 +29,14 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } + }, + "reauth": { + "title": "Synology DSM [%key:common::config_flow::title::reauth%]", + "description": "Reason: {details}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -39,7 +47,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index 74867aa9044..5a6c52872db 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/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": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -29,6 +30,14 @@ "description": "M\u00f6chtest du {name} ({host}) einrichten?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Ursache: {details}", + "title": "Synology DSM erneute Authentifizierung notwendig" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 397bad8b14e..0231f8ddb3c 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -29,6 +30,14 @@ "description": "Do you want to setup {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Reason: {details}", + "title": "Synology DSM Reauthenticate Integration" + }, "user": { "data": { "host": "Host", diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 0eb9cb66852..cf043c2ce5f 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -23,7 +23,7 @@ from homeassistant.components.synology_dsm.const import ( DEFAULT_VERIFY_SSL, DOMAIN, ) -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( CONF_DISKS, CONF_HOST, @@ -255,6 +255,56 @@ async def test_user_vdsm(hass: HomeAssistant, service_vdsm: MagicMock): assert result["data"].get(CONF_VOLUMES) is None +async def test_reauth(hass: HomeAssistant, service: MagicMock): + """Test reauthentication.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: f"{PASSWORD}_invalid", + }, + unique_id=SERIAL, + ).add_to_hass(hass) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + async def test_abort_if_already_setup(hass: HomeAssistant, service: MagicMock): """Test we abort if the account is already setup.""" MockConfigEntry( diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 891296d97ea..4d6708a2e79 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -2,7 +2,9 @@ from unittest.mock import patch import pytest +from synology_dsm.exceptions import SynologyDSMLoginInvalidException +from homeassistant import data_entry_flow from homeassistant.components.synology_dsm.const import DOMAIN, SERVICES from homeassistant.const import ( CONF_HOST, @@ -40,3 +42,29 @@ async def test_services_registered(hass: HomeAssistant): assert await hass.config_entries.async_setup(entry.entry_id) for service in SERVICES: assert hass.services.has_service(DOMAIN, service) + + +@pytest.mark.no_bypass_setup +async def test_reauth_triggered(hass: HomeAssistant): + """Test if reauthentication flow is triggered.""" + with patch( + "homeassistant.components.synology_dsm.SynoApi.async_setup", + side_effect=SynologyDSMLoginInvalidException(USERNAME), + ), patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", + return_value={"type": data_entry_flow.RESULT_TYPE_FORM}, + ) as mock_async_step_reauth: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + mock_async_step_reauth.assert_called_once() From 772cbd59d7b9593d35ed9425fc62b4cc61958884 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 21 Jul 2021 19:11:44 +0200 Subject: [PATCH 059/112] Improve typing in Shelly integration (#52544) --- .strict-typing | 1 + homeassistant/components/shelly/__init__.py | 78 ++++++++---- .../components/shelly/binary_sensor.py | 23 +++- .../components/shelly/config_flow.py | 56 ++++++--- homeassistant/components/shelly/const.py | 80 ++++++------ homeassistant/components/shelly/cover.py | 54 ++++---- .../components/shelly/device_trigger.py | 15 ++- homeassistant/components/shelly/entity.py | 116 +++++++++++------- homeassistant/components/shelly/light.py | 50 ++++---- homeassistant/components/shelly/logbook.py | 16 ++- homeassistant/components/shelly/sensor.py | 44 ++++--- homeassistant/components/shelly/switch.py | 26 ++-- homeassistant/components/shelly/utils.py | 40 +++--- mypy.ini | 11 ++ 14 files changed, 365 insertions(+), 245 deletions(-) diff --git a/.strict-typing b/.strict-typing index 40fb375ca16..735d9ca4a64 100644 --- a/.strict-typing +++ b/.strict-typing @@ -74,6 +74,7 @@ homeassistant.components.remote.* homeassistant.components.scene.* homeassistant.components.select.* homeassistant.components.sensor.* +homeassistant.components.shelly.* homeassistant.components.slack.* homeassistant.components.sonos.media_player homeassistant.components.ssdp.* diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 425ff11399b..48e27203288 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -1,7 +1,10 @@ """The Shelly integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import Any, Final, cast import aioshelly import async_timeout @@ -15,10 +18,11 @@ from homeassistant.const import ( CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, @@ -43,19 +47,19 @@ from .const import ( ) from .utils import get_coap_context, get_device_name, get_device_sleep_period -PLATFORMS = ["binary_sensor", "cover", "light", "sensor", "switch"] -SLEEPING_PLATFORMS = ["binary_sensor", "sensor"] -_LOGGER = logging.getLogger(__name__) +PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] +SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +_LOGGER: Final = logging.getLogger(__name__) -COAP_SCHEMA = vol.Schema( +COAP_SCHEMA: Final = vol.Schema( { vol.Optional(CONF_COAP_PORT, default=DEFAULT_COAP_PORT): cv.port, } ) -CONFIG_SCHEMA = vol.Schema({DOMAIN: COAP_SCHEMA}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: COAP_SCHEMA}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Shelly component.""" hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} @@ -113,7 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sleep_period = entry.data.get("sleep_period") @callback - def _async_device_online(_): + def _async_device_online(_: Any) -> None: _LOGGER.debug("Device %s is online, resuming setup", entry.title) hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None @@ -153,7 +157,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_device_setup( hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device -): +) -> None: """Set up a device that is online.""" device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ COAP @@ -174,9 +178,11 @@ async def async_device_setup( class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Wrapper for a Shelly device with Home Assistant specific functions.""" - def __init__(self, hass, entry, device: aioshelly.Device): + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device + ) -> None: """Initialize the Shelly device wrapper.""" - self.device_id = None + self.device_id: str | None = None sleep_period = entry.data["sleep_period"] if sleep_period: @@ -205,7 +211,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @callback - def _async_device_updates_handler(self): + def _async_device_updates_handler(self) -> None: """Handle device updates.""" if not self.device.initialized: return @@ -258,7 +264,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.name, ) - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data.""" if self.entry.data.get("sleep_period"): # Sleeping device, no point polling it, just mark it unavailable @@ -267,21 +273,21 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): _LOGGER.debug("Polling Shelly Device - %s", self.name) try: async with async_timeout.timeout(POLLING_TIMEOUT_SEC): - return await self.device.update() + await self.device.update() except OSError as err: raise update_coordinator.UpdateFailed("Error fetching data") from err @property - def model(self): + def model(self) -> str: """Model of the device.""" - return self.entry.data["model"] + return cast(str, self.entry.data["model"]) @property - def mac(self): + def mac(self) -> str: """Mac address of the device.""" - return self.entry.unique_id + return cast(str, self.entry.unique_id) - async def async_setup(self): + async def async_setup(self) -> None: """Set up the wrapper.""" dev_reg = await device_registry.async_get_registry(self.hass) sw_version = self.device.settings["fw"] if self.device.initialized else "" @@ -298,7 +304,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.device_id = entry.id self.device.subscribe_updates(self.async_set_updated_data) - def shutdown(self): + def shutdown(self) -> None: """Shutdown the wrapper.""" if self.device: self.device.shutdown() @@ -306,7 +312,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.device = None @callback - def _handle_ha_stop(self, _): + def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" _LOGGER.debug("Stopping ShellyDeviceWrapper for %s", self.name) self.shutdown() @@ -315,7 +321,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): """Rest Wrapper for a Shelly device with Home Assistant specific functions.""" - def __init__(self, hass, device: aioshelly.Device): + def __init__(self, hass: HomeAssistant, device: aioshelly.Device) -> None: """Initialize the Shelly device wrapper.""" if ( device.settings["device"]["type"] @@ -335,22 +341,22 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): ) self.device = device - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data.""" try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): _LOGGER.debug("REST update for %s", self.name) - return await self.device.update_status() + await self.device.update_status() except OSError as err: raise update_coordinator.UpdateFailed("Error fetching data") from err @property - def mac(self): + def mac(self) -> str: """Mac address of the device.""" - return self.device.settings["device"]["mac"] + return cast(str, self.device.settings["device"]["mac"]) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) if device is not None: @@ -370,3 +376,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) return unload_ok + + +def get_device_wrapper( + hass: HomeAssistant, device_id: str +) -> ShellyDeviceWrapper | None: + """Get a Shelly device wrapper for the given device id.""" + if not hass.data.get(DOMAIN): + return None + + for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: + wrapper: ShellyDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry + ].get(COAP) + + if wrapper and wrapper.device_id == device_id: + return wrapper + + return None diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 385b3b30c36..dd1b3a9d66d 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,4 +1,8 @@ """Binary sensor for Shelly.""" +from __future__ import annotations + +from typing import Final + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_GAS, @@ -12,6 +16,9 @@ from homeassistant.components.binary_sensor import ( STATE_ON, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( BlockAttributeDescription, @@ -24,7 +31,7 @@ from .entity import ( ) from .utils import is_momentary_input -SENSORS = { +SENSORS: Final = { ("device", "overtemp"): BlockAttributeDescription( name="Overheating", device_class=DEVICE_CLASS_PROBLEM ), @@ -83,7 +90,7 @@ SENSORS = { ), } -REST_SENSORS = { +REST_SENSORS: Final = { "cloud": RestAttributeDescription( name="Cloud", value=lambda status, _: status["cloud"]["connected"], @@ -103,7 +110,11 @@ REST_SENSORS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up sensors for device.""" if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( @@ -130,7 +141,7 @@ class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): """Shelly binary sensor entity.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor state is on.""" return bool(self.attribute_value) @@ -139,7 +150,7 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): """Shelly REST binary sensor entity.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if REST sensor state is on.""" return bool(self.attribute_value) @@ -150,7 +161,7 @@ class ShellySleepingBinarySensor( """Represent a shelly sleeping binary sensor.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor state is on.""" if self.block is not None: return bool(self.attribute_value) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 5bf8277066c..c4ddbc0b0aa 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Shelly integration.""" +from __future__ import annotations + import asyncio import logging +from typing import Any, Dict, Final, cast import aiohttp import aioshelly @@ -14,19 +17,23 @@ from homeassistant.const import ( CONF_USERNAME, HTTP_UNAUTHORIZED, ) +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import DiscoveryInfoType from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, DOMAIN from .utils import get_coap_context, get_device_sleep_period -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -HOST_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) +HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) -HTTP_CONNECT_ERRORS = (asyncio.TimeoutError, aiohttp.ClientError) +HTTP_CONNECT_ERRORS: Final = (asyncio.TimeoutError, aiohttp.ClientError) -async def validate_input(hass: core.HomeAssistant, host, data): +async def validate_input( + hass: core.HomeAssistant, host: str, data: dict[str, Any] +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -60,15 +67,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - host = None - info = None - device_info = None + host: str = "" + info: dict[str, Any] = {} + device_info: dict[str, Any] = {} - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - host = user_input[CONF_HOST] + host: str = user_input[CONF_HOST] try: info = await self._async_get_info(host) except HTTP_CONNECT_ERRORS: @@ -106,9 +115,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=HOST_SCHEMA, errors=errors ) - async def async_step_credentials(self, user_input=None): + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the credentials step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: device_info = await validate_input(self.hass, self.host, user_input) @@ -146,7 +157,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="credentials", data_schema=schema, errors=errors ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: """Handle zeroconf discovery.""" try: self.info = info = await self._async_get_info(discovery_info["host"]) @@ -173,9 +186,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_confirm_discovery() - async def async_step_confirm_discovery(self, user_input=None): + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle discovery confirm.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: return self.async_create_entry( title=self.device_info["title"] or self.device_info["hostname"], @@ -199,10 +214,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_get_info(self, host): + async def _async_get_info(self, host: str) -> dict[str, Any]: """Get info from shelly device.""" async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return await aioshelly.get_info( - aiohttp_client.async_get_clientsession(self.hass), - host, + return cast( + Dict[str, Any], + await aioshelly.get_info( + aiohttp_client.async_get_clientsession(self.hass), + host, + ), ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 119ae478bb7..2c401829c30 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -1,34 +1,37 @@ """Constants for the Shelly integration.""" +from __future__ import annotations -COAP = "coap" -DATA_CONFIG_ENTRY = "config_entry" -DEVICE = "device" -DOMAIN = "shelly" -REST = "rest" +from typing import Final -CONF_COAP_PORT = "coap_port" -DEFAULT_COAP_PORT = 5683 +COAP: Final = "coap" +DATA_CONFIG_ENTRY: Final = "config_entry" +DEVICE: Final = "device" +DOMAIN: Final = "shelly" +REST: Final = "rest" + +CONF_COAP_PORT: Final = "coap_port" +DEFAULT_COAP_PORT: Final = 5683 # Used in "_async_update_data" as timeout for polling data from devices. -POLLING_TIMEOUT_SEC = 18 +POLLING_TIMEOUT_SEC: Final = 18 # Refresh interval for REST sensors -REST_SENSORS_UPDATE_INTERVAL = 60 +REST_SENSORS_UPDATE_INTERVAL: Final = 60 # Timeout used for aioshelly calls -AIOSHELLY_DEVICE_TIMEOUT_SEC = 10 +AIOSHELLY_DEVICE_TIMEOUT_SEC: Final = 10 # Multiplier used to calculate the "update_interval" for sleeping devices. -SLEEP_PERIOD_MULTIPLIER = 1.2 +SLEEP_PERIOD_MULTIPLIER: Final = 1.2 # Multiplier used to calculate the "update_interval" for non-sleeping devices. -UPDATE_PERIOD_MULTIPLIER = 2.2 +UPDATE_PERIOD_MULTIPLIER: Final = 2.2 # Shelly Air - Maximum work hours before lamp replacement -SHAIR_MAX_WORK_HOURS = 9000 +SHAIR_MAX_WORK_HOURS: Final = 9000 # Map Shelly input events -INPUTS_EVENTS_DICT = { +INPUTS_EVENTS_DICT: Final = { "S": "single", "SS": "double", "SSS": "triple", @@ -38,28 +41,20 @@ INPUTS_EVENTS_DICT = { } # List of battery devices that maintain a permanent WiFi connection -BATTERY_DEVICES_WITH_PERMANENT_CONNECTION = ["SHMOS-01"] +BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"] -EVENT_SHELLY_CLICK = "shelly.click" +EVENT_SHELLY_CLICK: Final = "shelly.click" -ATTR_CLICK_TYPE = "click_type" -ATTR_CHANNEL = "channel" -ATTR_DEVICE = "device" -CONF_SUBTYPE = "subtype" +ATTR_CLICK_TYPE: Final = "click_type" +ATTR_CHANNEL: Final = "channel" +ATTR_DEVICE: Final = "device" +CONF_SUBTYPE: Final = "subtype" -BASIC_INPUTS_EVENTS_TYPES = { - "single", - "long", -} +BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"} -SHBTN_INPUTS_EVENTS_TYPES = { - "single", - "double", - "triple", - "long", -} +SHBTN_INPUTS_EVENTS_TYPES: Final = {"single", "double", "triple", "long"} -SUPPORTED_INPUTS_EVENTS_TYPES = SHIX3_1_INPUTS_EVENTS_TYPES = { +SUPPORTED_INPUTS_EVENTS_TYPES: Final = { "single", "double", "triple", @@ -68,23 +63,20 @@ SUPPORTED_INPUTS_EVENTS_TYPES = SHIX3_1_INPUTS_EVENTS_TYPES = { "long_single", } -INPUTS_EVENTS_SUBTYPES = { - "button": 1, - "button1": 1, - "button2": 2, - "button3": 3, -} +SHIX3_1_INPUTS_EVENTS_TYPES = SUPPORTED_INPUTS_EVENTS_TYPES -SHBTN_MODELS = ["SHBTN-1", "SHBTN-2"] +INPUTS_EVENTS_SUBTYPES: Final = {"button": 1, "button1": 1, "button2": 2, "button3": 3} -STANDARD_RGB_EFFECTS = { +SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"] + +STANDARD_RGB_EFFECTS: Final = { 0: "Off", 1: "Meteor Shower", 2: "Gradual Change", 3: "Flash", } -SHBLB_1_RGB_EFFECTS = { +SHBLB_1_RGB_EFFECTS: Final = { 0: "Off", 1: "Meteor Shower", 2: "Gradual Change", @@ -95,8 +87,8 @@ SHBLB_1_RGB_EFFECTS = { } # Kelvin value for colorTemp -KELVIN_MAX_VALUE = 6500 -KELVIN_MIN_VALUE_WHITE = 2700 -KELVIN_MIN_VALUE_COLOR = 3000 +KELVIN_MAX_VALUE: Final = 6500 +KELVIN_MIN_VALUE_WHITE: Final = 2700 +KELVIN_MIN_VALUE_COLOR: Final = 3000 -UPTIME_DEVIATION = 5 +UPTIME_DEVIATION: Final = 5 diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index dc2dba654f3..73b8b1baae3 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -1,4 +1,8 @@ """Cover for Shelly.""" +from __future__ import annotations + +from typing import Any, cast + from aioshelly import Block from homeassistant.components.cover import ( @@ -10,14 +14,20 @@ from homeassistant.components.cover import ( SUPPORT_STOP, CoverEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ShellyDeviceWrapper from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up cover for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] blocks = [block for block in wrapper.device.blocks if block.type == "roller"] @@ -36,72 +46,72 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) - self.control_result = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + self.control_result: dict[str, Any] | None = None + self._supported_features: int = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP if self.wrapper.device.settings["rollers"][0]["positioning"]: self._supported_features |= SUPPORT_SET_POSITION @property - def is_closed(self): + def is_closed(self) -> bool: """If cover is closed.""" if self.control_result: - return self.control_result["current_pos"] == 0 + return cast(bool, self.control_result["current_pos"] == 0) - return self.block.rollerPos == 0 + return cast(bool, self.block.rollerPos == 0) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Position of the cover.""" if self.control_result: - return self.control_result["current_pos"] + return cast(int, self.control_result["current_pos"]) - return self.block.rollerPos + return cast(int, self.block.rollerPos) @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing.""" if self.control_result: - return self.control_result["state"] == "close" + return cast(bool, self.control_result["state"] == "close") - return self.block.roller == "close" + return cast(bool, self.block.roller == "close") @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening.""" if self.control_result: - return self.control_result["state"] == "open" + return cast(bool, self.control_result["state"] == "open") - return self.block.roller == "open" + return cast(bool, self.block.roller == "open") @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return self._supported_features - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" self.control_result = await self.set_state(go="close") self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" self.control_result = await self.set_state(go="open") self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.control_result = await self.set_state( go="to_pos", roller_pos=kwargs[ATTR_POSITION] ) self.async_write_ha_state() - async def async_stop_cover(self, **_kwargs): + async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" self.control_result = await self.set_state(go="stop") self.async_write_ha_state() @callback - def _update_callback(self): + def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index e767f49bcbb..bcb909555a9 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for Shelly.""" from __future__ import annotations +from typing import Any, Final + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -20,6 +22,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -31,9 +34,9 @@ from .const import ( SHBTN_MODELS, SUPPORTED_INPUTS_EVENTS_TYPES, ) -from .utils import get_device_wrapper, get_input_triggers +from .utils import get_input_triggers -TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(SUPPORTED_INPUTS_EVENTS_TYPES), vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES), @@ -41,7 +44,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_validate_trigger_config(hass, config): +async def async_validate_trigger_config( + hass: HomeAssistant, config: dict[str, Any] +) -> dict[str, Any]: """Validate config.""" config = TRIGGER_SCHEMA(config) @@ -62,7 +67,9 @@ async def async_validate_trigger_config(hass, 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, str]]: """List device triggers for Shelly devices.""" triggers = [] diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 744272ccf91..743dd07414e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -4,31 +4,39 @@ from __future__ import annotations import asyncio from dataclasses import dataclass import logging -from typing import Any, Callable +from typing import Any, Callable, Final, cast import aioshelly import async_timeout from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( device_registry, entity, entity_registry, update_coordinator, ) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import StateType from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, COAP, DATA_CONFIG_ENTRY, DOMAIN, REST from .utils import async_remove_shelly_entity, get_entity_name -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) async def async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, sensors, sensor_class -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: dict[tuple[str, str], BlockAttributeDescription], + sensor_class: Callable, +) -> None: """Set up entities for attributes.""" wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id @@ -45,8 +53,12 @@ async def async_setup_entry_attribute_entities( async def async_setup_block_attribute_entities( - hass, async_add_entities, wrapper, sensors, sensor_class -): + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + wrapper: ShellyDeviceWrapper, + sensors: dict[tuple[str, str], BlockAttributeDescription], + sensor_class: Callable, +) -> None: """Set up entities for block attributes.""" blocks = [] @@ -82,8 +94,13 @@ async def async_setup_block_attribute_entities( async def async_restore_block_attribute_entities( - hass, config_entry, async_add_entities, wrapper, sensors, sensor_class -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + wrapper: ShellyDeviceWrapper, + sensors: dict[tuple[str, str], BlockAttributeDescription], + sensor_class: Callable, +) -> None: """Restore block attributes entities.""" entities = [] @@ -117,8 +134,12 @@ async def async_restore_block_attribute_entities( async def async_setup_entry_rest( - hass, config_entry, async_add_entities, sensors, sensor_class -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: dict[str, RestAttributeDescription], + sensor_class: Callable, +) -> None: """Set up entities for REST sensors.""" wrapper: ShellyDeviceRestWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id @@ -177,53 +198,53 @@ class RestAttributeDescription: class ShellyBlockEntity(entity.Entity): """Helper class to represent a block.""" - def __init__(self, wrapper: ShellyDeviceWrapper, block): + def __init__(self, wrapper: ShellyDeviceWrapper, block: aioshelly.Block) -> None: """Initialize Shelly entity.""" self.wrapper = wrapper self.block = block - self._name: str | None = get_entity_name(wrapper.device, block) + self._name = get_entity_name(wrapper.device, block) @property - def name(self): + def name(self) -> str: """Name of entity.""" return self._name @property - def should_poll(self): + def should_poll(self) -> bool: """If device should be polled.""" return False @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info.""" return { "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} } @property - def available(self): + def available(self) -> bool: """Available.""" return self.wrapper.last_update_success @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID of entity.""" return f"{self.wrapper.mac}-{self.block.description}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) - async def async_update(self): + async def async_update(self) -> None: """Update entity with latest info.""" await self.wrapper.async_request_refresh() @callback - def _update_callback(self): + def _update_callback(self) -> None: """Handle device update.""" self.async_write_ha_state() - async def set_state(self, **kwargs): + async def set_state(self, **kwargs: Any) -> Any: """Set block state (HTTP request).""" _LOGGER.debug("Setting state for entity %s, state: %s", self.name, kwargs) try: @@ -261,16 +282,16 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): unit = unit(block.info(attribute)) self._unit: None | str | Callable[[dict], str] = unit - self._unique_id: None | str = f"{super().unique_id}-{self.attribute}" + self._unique_id: str = f"{super().unique_id}-{self.attribute}" self._name = get_entity_name(wrapper.device, block, self.description.name) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID of entity.""" return self._unique_id @property - def name(self): + def name(self) -> str: """Name of sensor.""" return self._name @@ -280,27 +301,27 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): return self.description.default_enabled @property - def attribute_value(self): + def attribute_value(self) -> StateType: """Value of sensor.""" value = getattr(self.block, self.attribute) if value is None: return None - return self.description.value(value) + return cast(StateType, self.description.value(value)) @property - def device_class(self): + def device_class(self) -> str | None: """Device class of sensor.""" return self.description.device_class @property - def icon(self): + def icon(self) -> str | None: """Icon of sensor.""" return self.description.icon @property - def available(self): + def available(self) -> bool: """Available.""" available = super().available @@ -310,7 +331,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): return self.description.available(self.block) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self.description.extra_state_attributes is None: return None @@ -336,12 +357,12 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): self._last_value = None @property - def name(self): + def name(self) -> str: """Name of sensor.""" return self._name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info.""" return { "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} @@ -353,35 +374,36 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): return self.description.default_enabled @property - def available(self): + def available(self) -> bool: """Available.""" return self.wrapper.last_update_success @property - def attribute_value(self): + def attribute_value(self) -> StateType: """Value of sensor.""" - self._last_value = self.description.value( - self.wrapper.device.status, self._last_value - ) + if callable(self.description.value): + self._last_value = self.description.value( + self.wrapper.device.status, self._last_value + ) return self._last_value @property - def device_class(self): + def device_class(self) -> str | None: """Device class of sensor.""" return self.description.device_class @property - def icon(self): + def icon(self) -> str | None: """Icon of sensor.""" return self.description.icon @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID of entity.""" return f"{self.wrapper.mac}-{self.attribute}" @property - def extra_state_attributes(self) -> dict | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self.description.extra_state_attributes is None: return None @@ -400,11 +422,11 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti attribute: str, description: BlockAttributeDescription, entry: entity_registry.RegistryEntry | None = None, - sensors: set | None = None, + sensors: dict[tuple[str, str], BlockAttributeDescription] | None = None, ) -> None: """Initialize the sleeping sensor.""" self.sensors = sensors - self.last_state = None + self.last_state: StateType = None self.wrapper = wrapper self.attribute = attribute self.block = block @@ -421,9 +443,9 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti ) elif entry is not None: self._unique_id = entry.unique_id - self._name = entry.original_name + self._name = cast(str, entry.original_name) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() @@ -434,7 +456,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self.description.state_class = last_state.attributes.get(ATTR_STATE_CLASS) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Handle device update.""" if ( self.block is not None diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 8314650d548..047a105a30f 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any +from typing import Any, Final, cast from aioshelly import Block import async_timeout @@ -23,7 +23,9 @@ from homeassistant.components.light import ( LightEntity, brightness_supported, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import ( color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, @@ -44,10 +46,14 @@ from .const import ( from .entity import ShellyBlockEntity from .utils import async_remove_shelly_entity -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up lights for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] @@ -78,12 +84,12 @@ class ShellyLight(ShellyBlockEntity, LightEntity): def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) - self.control_result = None - self.mode_result = None - self._supported_color_modes = set() - self._supported_features = 0 - self._min_kelvin = KELVIN_MIN_VALUE_WHITE - self._max_kelvin = KELVIN_MAX_VALUE + self.control_result: dict[str, Any] | None = None + self.mode_result: dict[str, Any] | None = None + self._supported_color_modes: set[str] = set() + self._supported_features: int = 0 + self._min_kelvin: int = KELVIN_MIN_VALUE_WHITE + self._max_kelvin: int = KELVIN_MAX_VALUE if hasattr(block, "red") and hasattr(block, "green") and hasattr(block, "blue"): self._min_kelvin = KELVIN_MIN_VALUE_COLOR @@ -113,18 +119,18 @@ class ShellyLight(ShellyBlockEntity, LightEntity): def is_on(self) -> bool: """If light is on.""" if self.control_result: - return self.control_result["ison"] + return cast(bool, self.control_result["ison"]) - return self.block.output + return bool(self.block.output) @property - def mode(self) -> str | None: + def mode(self) -> str: """Return the color mode of the light.""" if self.mode_result: - return self.mode_result["mode"] + return cast(str, self.mode_result["mode"]) if hasattr(self.block, "mode"): - return self.block.mode + return cast(str, self.block.mode) if ( hasattr(self.block, "red") @@ -136,7 +142,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return "white" @property - def brightness(self) -> int | None: + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" if self.mode == "color": if self.control_result: @@ -152,7 +158,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return round(255 * brightness_pct / 100) @property - def color_mode(self) -> str | None: + def color_mode(self) -> str: """Return the color mode of the light.""" if self.mode == "color": if hasattr(self.block, "white"): @@ -191,7 +197,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return (*self.rgb_color, white) @property - def color_temp(self) -> int | None: + def color_temp(self) -> int: """Return the CT color value in mireds.""" if self.control_result: color_temp = self.control_result["temp"] @@ -244,7 +250,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return STANDARD_RGB_EFFECTS[effect_index] - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" if self.block.type == "relay": self.control_result = await self.set_state(turn="on") @@ -304,12 +310,12 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" self.control_result = await self.set_state(turn="off") self.async_write_ha_state() - async def set_light_mode(self, set_mode): + async def set_light_mode(self, set_mode: str | None) -> bool: """Change device mode color/white if mode has changed.""" if set_mode is None or self.mode == set_mode: return True @@ -331,7 +337,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return True @callback - def _update_callback(self): + def _update_callback(self) -> None: """When device updates, clear control & mode result that overrides state.""" self.control_result = None self.mode_result = None diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index 78a5c279a93..5b0ada6f166 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -1,8 +1,13 @@ """Describe Shelly logbook events.""" +from __future__ import annotations + +from typing import Callable from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import EventType +from . import get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -10,15 +15,18 @@ from .const import ( DOMAIN, EVENT_SHELLY_CLICK, ) -from .utils import get_device_name, get_device_wrapper +from .utils import get_device_name @callback -def async_describe_events(hass, async_describe_event): +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[EventType], dict]], None], +) -> None: """Describe logbook events.""" @callback - def async_describe_shelly_click_event(event): + 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: diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 8a435c3e50f..96ff6e55f8d 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,6 +1,11 @@ """Sensor for Shelly.""" +from __future__ import annotations + +from typing import Final, cast + from homeassistant.components import sensor from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEGREE, @@ -12,6 +17,9 @@ from homeassistant.const import ( POWER_WATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import SHAIR_MAX_WORK_HOURS from .entity import ( @@ -25,7 +33,7 @@ from .entity import ( ) from .utils import get_device_uptime, temperature_unit -SENSORS = { +SENSORS: Final = { ("device", "battery"): BlockAttributeDescription( name="Battery", unit=PERCENTAGE, @@ -153,7 +161,7 @@ SENSORS = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_TEMPERATURE, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: block.extTemp != 999, + available=lambda block: cast(bool, block.extTemp != 999), ), ("sensor", "humidity"): BlockAttributeDescription( name="Humidity", @@ -161,7 +169,7 @@ SENSORS = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_HUMIDITY, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: block.extTemp != 999, + available=lambda block: cast(bool, block.extTemp != 999), ), ("sensor", "luminosity"): BlockAttributeDescription( name="Luminosity", @@ -199,7 +207,7 @@ SENSORS = { ), } -REST_SENSORS = { +REST_SENSORS: Final = { "rssi": RestAttributeDescription( name="RSSI", unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -217,7 +225,11 @@ REST_SENSORS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up sensors for device.""" if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( @@ -236,36 +248,36 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): """Represent a shelly sensor.""" @property - def state(self): + def state(self) -> StateType: """Return value of sensor.""" return self.attribute_value @property - def state_class(self): + def state_class(self) -> str | None: """State class of sensor.""" return self.description.state_class @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" - return self._unit + return cast(str, self._unit) class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): """Represent a shelly REST sensor.""" @property - def state(self): + def state(self) -> StateType: """Return value of sensor.""" return self.attribute_value @property - def state_class(self): + def state_class(self) -> str | None: """State class of sensor.""" return self.description.state_class @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return self.description.unit @@ -274,7 +286,7 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): """Represent a shelly sleeping sensor.""" @property - def state(self): + def state(self) -> StateType: """Return value of sensor.""" if self.block is not None: return self.attribute_value @@ -282,11 +294,11 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): return self.last_state @property - def state_class(self): + def state_class(self) -> str | None: """State class of sensor.""" return self.description.state_class @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" - return self._unit + return cast(str, self._unit) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 6f3dd0b0136..3e35ba878e4 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -1,8 +1,14 @@ """Switch for Shelly.""" +from __future__ import annotations + +from typing import Any, cast + from aioshelly import Block from homeassistant.components.switch import SwitchEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ShellyDeviceWrapper from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN @@ -10,7 +16,11 @@ from .entity import ShellyBlockEntity from .utils import async_remove_shelly_entity -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up switches for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] @@ -50,28 +60,28 @@ class RelaySwitch(ShellyBlockEntity, SwitchEntity): def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: """Initialize relay switch.""" super().__init__(wrapper, block) - self.control_result = None + self.control_result: dict[str, Any] | None = None @property def is_on(self) -> bool: """If switch is on.""" if self.control_result: - return self.control_result["ison"] + return cast(bool, self.control_result["ison"]) - return self.block.output + return bool(self.block.output) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on relay.""" self.control_result = await self.set_state(turn="on") self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off relay.""" self.control_result = await self.set_state(turn="off") self.async_write_ha_state() @callback - def _update_callback(self): + def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 37b34dfe9e8..d8ce5ae9e45 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -3,19 +3,19 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Any, Final, cast import aioshelly from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton +from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow from .const import ( BASIC_INPUTS_EVENTS_TYPES, - COAP, CONF_COAP_PORT, - DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, DOMAIN, SHBTN_INPUTS_EVENTS_TYPES, @@ -24,10 +24,12 @@ from .const import ( UPTIME_DEVIATION, ) -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -async def async_remove_shelly_entity(hass, domain, unique_id): +async def async_remove_shelly_entity( + hass: HomeAssistant, domain: str, unique_id: str +) -> None: """Remove a Shelly entity.""" entity_reg = await hass.helpers.entity_registry.async_get_registry() entity_id = entity_reg.async_get_entity_id(domain, DOMAIN, unique_id) @@ -36,7 +38,7 @@ async def async_remove_shelly_entity(hass, domain, unique_id): entity_reg.async_remove(entity_id) -def temperature_unit(block_info: dict) -> str: +def temperature_unit(block_info: dict[str, Any]) -> str: """Detect temperature unit.""" if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F": return TEMP_FAHRENHEIT @@ -45,7 +47,7 @@ def temperature_unit(block_info: dict) -> str: def get_device_name(device: aioshelly.Device) -> str: """Naming for device.""" - return device.settings["name"] or device.settings["device"]["hostname"] + return cast(str, device.settings["name"] or device.settings["device"]["hostname"]) def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) -> int: @@ -96,7 +98,7 @@ def get_device_channel_name( ): return entity_name - channel_name = None + channel_name: str | None = None mode = block.type + "s" if mode in device.settings: channel_name = device.settings[mode][int(block.channel)].get("name") @@ -112,7 +114,7 @@ def get_device_channel_name( return f"{entity_name} channel {chr(int(block.channel)+base)}" -def is_momentary_input(settings: dict, block: aioshelly.Block) -> bool: +def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool: """Return true if input button settings is set to a momentary type.""" # Shelly Button type is fixed to momentary and no btn_type if settings["device"]["type"] in SHBTN_MODELS: @@ -134,7 +136,7 @@ def is_momentary_input(settings: dict, block: aioshelly.Block) -> bool: return button_type in ["momentary", "momentary_on_release"] -def get_device_uptime(status: dict, last_uptime: str) -> str: +def get_device_uptime(status: dict[str, Any], last_uptime: str) -> str: """Return device uptime string, tolerate up to 5 seconds deviation.""" delta_uptime = utcnow() - timedelta(seconds=status["uptime"]) @@ -178,22 +180,8 @@ def get_input_triggers( return triggers -def get_device_wrapper(hass: HomeAssistant, device_id: str): - """Get a Shelly device wrapper for the given device id.""" - if not hass.data.get(DOMAIN): - return None - - for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(COAP) - - if wrapper and wrapper.device_id == device_id: - return wrapper - - return None - - @singleton.singleton("shelly_coap") -async def get_coap_context(hass): +async def get_coap_context(hass: HomeAssistant) -> aioshelly.COAP: """Get CoAP context to be used in all Shelly devices.""" context = aioshelly.COAP() if DOMAIN in hass.data: @@ -204,7 +192,7 @@ async def get_coap_context(hass): await context.initialize(port) @callback - def shutdown_listener(ev): + def shutdown_listener(ev: EventType) -> None: context.close() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) @@ -212,7 +200,7 @@ async def get_coap_context(hass): return context -def get_device_sleep_period(settings: dict) -> int: +def get_device_sleep_period(settings: dict[str, Any]) -> int: """Return the device sleep period in seconds or 0 for non sleeping devices.""" sleep_period = 0 diff --git a/mypy.ini b/mypy.ini index cf85d2e60a9..22cd7f4a1e0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -825,6 +825,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.shelly.*] +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.slack.*] check_untyped_defs = true disallow_incomplete_defs = true From 2d48d273a79b0c321e6aeaf1f915942d70758f47 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 21 Jul 2021 19:12:32 +0200 Subject: [PATCH 060/112] Fix incorrect unit (#53274) --- homeassistant/components/fritz/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 031f7bc555c..215848251e9 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntit from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_GIGABYTES, + DATA_RATE_KILOBITS_PER_SECOND, DATA_RATE_KILOBYTES_PER_SECOND, DEVICE_CLASS_TIMESTAMP, ) @@ -113,14 +114,14 @@ SENSOR_DATA = { state_provider=_retrieve_kb_s_received_state, ), "max_kb_s_sent": SensorData( - name="Max kB/s sent", - unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + name="Max kbit/s sent", + 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 kB/s received", - unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + name="Max kbit/s received", + unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:download", state_provider=_retrieve_max_kb_s_received_state, ), From db1a8e9336ca0faf31b7654ba15be449ab28df21 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 21 Jul 2021 19:31:51 +0200 Subject: [PATCH 061/112] Fix similar network names for Fritz (#53278) --- homeassistant/components/fritz/switch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 2969095d34d..b1ec63e0ce9 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -13,6 +13,7 @@ from fritzconnection.core.exceptions import ( FritzSecurityError, FritzServiceError, ) +import slugify as unicode_slug import xmltodict from homeassistant.components.switch import SwitchEntity @@ -247,7 +248,7 @@ def wifi_entities_list( ) if network_info: ssid = network_info["NewSSID"] - if ssid in networks.values(): + if unicode_slug.slugify(ssid, lowercase=False) in networks.values(): networks[i] = f'{ssid} {std_table[network_info["NewStandard"]]}' else: networks[i] = ssid From a1df3519db58d055d3ae0ac03ac90e346495bc48 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 13:37:12 -0400 Subject: [PATCH 062/112] Use entity class attributes for Bsblan (#53165) --- .coveragerc | 2 - homeassistant/components/bsblan/climate.py | 117 +++++---------------- 2 files changed, 29 insertions(+), 90 deletions(-) diff --git a/.coveragerc b/.coveragerc index 83212125cb7..44bb49e5f57 100644 --- a/.coveragerc +++ b/.coveragerc @@ -132,9 +132,7 @@ omit = homeassistant/components/brottsplatskartan/sensor.py homeassistant/components/browser/* homeassistant/components/brunt/cover.py - homeassistant/components/bsblan/__init__.py homeassistant/components/bsblan/climate.py - homeassistant/components/bsblan/const.py homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py homeassistant/components/buienradar/sensor.py diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 3aa3679c6c9..160c4f9d9b3 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -27,7 +27,6 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -88,6 +87,10 @@ async def async_setup_entry( class BSBLanClimate(ClimateEntity): """Defines a BSBLan climate device.""" + _attr_supported_features = SUPPORT_FLAGS + _attr_hvac_modes = HVAC_MODES + _attr_preset_modes = PRESET_MODES + def __init__( self, entry_id: str, @@ -95,89 +98,33 @@ class BSBLanClimate(ClimateEntity): info: Info, ) -> None: """Initialize BSBLan climate device.""" - self._current_temperature: float | None = None - self._available = True - self._hvac_mode: str | None = None - self._target_temperature: float | None = None - self._temperature_unit = None - self._preset_mode: str | None = None + self._attr_available = True self._store_hvac_mode = None - self._info: Info = info self.bsblan = bsblan - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._info.device_identification - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._info.device_identification - - @property - def temperature_unit(self) -> str: - """Return the unit of measurement which this thermostat uses.""" - if self._temperature_unit == "°C": - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_FLAGS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def hvac_mode(self): - """Return the current operation mode.""" - return self._hvac_mode - - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return HVAC_MODES - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def preset_modes(self): - """List of available preset modes.""" - return PRESET_MODES - - @property - def preset_mode(self): - """Return the preset_mode.""" - return self._preset_mode + self._attr_name = self._attr_unique_id = info.device_identification + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, info.device_identification)}, + ATTR_NAME: "BSBLan Device", + ATTR_MANUFACTURER: "BSBLan", + ATTR_MODEL: info.controller_variant, + } async def async_set_preset_mode(self, preset_mode): """Set preset mode.""" _LOGGER.debug("Setting preset mode to: %s", preset_mode) if preset_mode == PRESET_NONE: # restore previous hvac mode - self._hvac_mode = self._store_hvac_mode + self._attr_hvac_mode = self._store_hvac_mode else: # Store hvac mode. - self._store_hvac_mode = self._hvac_mode + self._store_hvac_mode = self._attr_hvac_mode await self.async_set_data(preset_mode=preset_mode) async def async_set_hvac_mode(self, hvac_mode): """Set HVAC mode.""" _LOGGER.debug("Setting HVAC mode to: %s", hvac_mode) # preset should be none when hvac mode is set - self._preset_mode = PRESET_NONE + self._attr_preset_mode = PRESET_NONE await self.async_set_data(hvac_mode=hvac_mode) async def async_set_temperature(self, **kwargs): @@ -204,39 +151,33 @@ class BSBLanClimate(ClimateEntity): await self.bsblan.thermostat(**data) except BSBLanError: _LOGGER.error("An error occurred while updating the BSBLan device") - self._available = False + self._attr_available = False async def async_update(self) -> None: """Update BSBlan entity.""" try: state: State = await self.bsblan.state() except BSBLanError: - if self._available: + if self.available: _LOGGER.error("An error occurred while updating the BSBLan device") - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True - self._current_temperature = float(state.current_temperature.value) - self._target_temperature = float(state.target_temperature.value) + self._attr_current_temperature = float(state.current_temperature.value) + self._attr_target_temperature = float(state.target_temperature.value) # check if preset is active else get hvac mode _LOGGER.debug("state hvac/preset mode: %s", state.hvac_mode.value) if state.hvac_mode.value == "2": - self._preset_mode = PRESET_ECO + self._attr_preset_mode = PRESET_ECO else: - self._hvac_mode = BSBLAN_TO_HA_STATE[state.hvac_mode.value] - self._preset_mode = PRESET_NONE + self._attr_hvac_mode = BSBLAN_TO_HA_STATE[state.hvac_mode.value] + self._attr_preset_mode = PRESET_NONE - self._temperature_unit = state.current_temperature.unit - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this BSBLan device.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._info.device_identification)}, - ATTR_NAME: "BSBLan Device", - ATTR_MANUFACTURER: "BSBLan", - ATTR_MODEL: self._info.controller_variant, - } + self._attr_temperature_unit = ( + TEMP_CELSIUS + if state.current_temperature.unit == "°C" + else TEMP_FAHRENHEIT + ) From aed7cb91205d69e213220ba27aed20314657cea8 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 21 Jul 2021 19:42:30 +0200 Subject: [PATCH 063/112] Convert skybell to use NamedTuple (#53269) * Convert to NamedTuple. * Second version. * Use names instead of index. * Review comments. * Add meta variable. * Review comment. * Review comments. --- .../components/skybell/binary_sensor.py | 39 ++++++++++++------- homeassistant/components/skybell/switch.py | 30 ++++++++------ 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index 7e075fba38a..c6e8200c812 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,5 +1,8 @@ """Binary sensor support for the Skybell HD Doorbell.""" +from __future__ import annotations + from datetime import timedelta +from typing import NamedTuple import voluptuous as vol @@ -16,10 +19,26 @@ from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice SCAN_INTERVAL = timedelta(seconds=10) -# Sensor types: Name, device_class, event + +class SkybellBinarySensorMetadata(NamedTuple): + """Metadata for an individual Skybell binary_sensor.""" + + name: str + device_class: str + event: str + + SENSOR_TYPES = { - "button": ["Button", DEVICE_CLASS_OCCUPANCY, "device:sensor:button"], - "motion": ["Motion", DEVICE_CLASS_MOTION, "device:sensor:motion"], + "button": SkybellBinarySensorMetadata( + "Button", + device_class=DEVICE_CLASS_OCCUPANCY, + event="device:sensor:button", + ), + "motion": SkybellBinarySensorMetadata( + "Motion", + device_class=DEVICE_CLASS_MOTION, + event="device:sensor:motion", + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -53,18 +72,12 @@ class SkybellBinarySensor(SkybellDevice, BinarySensorEntity): """Initialize a binary sensor for a Skybell device.""" super().__init__(device) self._sensor_type = sensor_type - self._name = "{} {}".format( - self._device.name, SENSOR_TYPES[self._sensor_type][0] - ) - self._device_class = SENSOR_TYPES[self._sensor_type][1] + self._metadata = SENSOR_TYPES[self._sensor_type] + self._attr_name = f"{self._device.name} {self._metadata.name}" + self._device_class = self._metadata.device_class self._event = {} self._state = 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.""" @@ -88,7 +101,7 @@ class SkybellBinarySensor(SkybellDevice, BinarySensorEntity): """Get the latest data and updates the state.""" super().update() - event = self._device.latest(SENSOR_TYPES[self._sensor_type][2]) + event = self._device.latest(self._metadata.event) self._state = bool(event and event.get("id") != self._event.get("id")) diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index 1ad13af9249..5f9706de4d1 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -1,4 +1,8 @@ """Switch support for the Skybell HD Doorbell.""" +from __future__ import annotations + +from typing import NamedTuple + import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -7,10 +11,20 @@ import homeassistant.helpers.config_validation as cv from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice -# Switch types: Name + +class SkybellSwitchMetadata(NamedTuple): + """Metadata for an individual Skybell switch.""" + + name: str + + SWITCH_TYPES = { - "do_not_disturb": ["Do Not Disturb"], - "motion_sensor": ["Motion Sensor"], + "do_not_disturb": SkybellSwitchMetadata( + "Do Not Disturb", + ), + "motion_sensor": SkybellSwitchMetadata( + "Motion Sensor", + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -44,14 +58,8 @@ class SkybellSwitch(SkybellDevice, SwitchEntity): """Initialize a light for a Skybell device.""" super().__init__(device) self._switch_type = switch_type - self._name = "{} {}".format( - self._device.name, SWITCH_TYPES[self._switch_type][0] - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + metadata = SWITCH_TYPES[self._switch_type] + self._attr_name = f"{self._device.name} {metadata.name}" def turn_on(self, **kwargs): """Turn on the switch.""" From 217c625c9be7a1ed02e1aece39b293f7bac0b5c5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 21 Jul 2021 19:43:33 +0200 Subject: [PATCH 064/112] Convert ebox to use NamedTuple (#53272) * Convert to use NamedTuple. * Convert to NamedTuple. * Use _attr variables. * Review comments. --- homeassistant/components/ebox/sensor.py | 115 ++++++++++++++++-------- 1 file changed, 80 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 72d169f389e..66a5beda3d2 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -3,8 +3,11 @@ Support for EBox. Get data from 'My Usage Page' page: https://client.ebox.ca/myusage """ +from __future__ import annotations + from datetime import timedelta import logging +from typing import NamedTuple from pyebox import EboxClient from pyebox.client import PyEboxError @@ -34,24 +37,81 @@ REQUESTS_TIMEOUT = 15 SCAN_INTERVAL = timedelta(minutes=15) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +class EboxSensorMetadata(NamedTuple): + """Metadata for an individual ebox sensor.""" + + name: str + unit_of_measurement: str + icon: str + + SENSOR_TYPES = { - "usage": ["Usage", PERCENTAGE, "mdi:percent"], - "balance": ["Balance", PRICE, "mdi:cash-usd"], - "limit": ["Data limit", DATA_GIGABITS, "mdi:download"], - "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"], - "before_offpeak_download": [ + "usage": EboxSensorMetadata( + "Usage", + unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + "balance": EboxSensorMetadata( + "Balance", + unit_of_measurement=PRICE, + icon="mdi:cash-usd", + ), + "limit": EboxSensorMetadata( + "Data limit", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + "days_left": EboxSensorMetadata( + "Days left", + unit_of_measurement=TIME_DAYS, + icon="mdi:calendar-today", + ), + "before_offpeak_download": EboxSensorMetadata( "Download before offpeak", - DATA_GIGABITS, - "mdi:download", - ], - "before_offpeak_upload": ["Upload before offpeak", DATA_GIGABITS, "mdi:upload"], - "before_offpeak_total": ["Total before offpeak", DATA_GIGABITS, "mdi:download"], - "offpeak_download": ["Offpeak download", DATA_GIGABITS, "mdi:download"], - "offpeak_upload": ["Offpeak Upload", DATA_GIGABITS, "mdi:upload"], - "offpeak_total": ["Offpeak Total", DATA_GIGABITS, "mdi:download"], - "download": ["Download", DATA_GIGABITS, "mdi:download"], - "upload": ["Upload", DATA_GIGABITS, "mdi:upload"], - "total": ["Total", DATA_GIGABITS, "mdi:download"], + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + "before_offpeak_upload": EboxSensorMetadata( + "Upload before offpeak", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + "before_offpeak_total": EboxSensorMetadata( + "Total before offpeak", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + "offpeak_download": EboxSensorMetadata( + "Offpeak download", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + "offpeak_upload": EboxSensorMetadata( + "Offpeak Upload", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + "offpeak_total": EboxSensorMetadata( + "Offpeak Total", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + "download": EboxSensorMetadata( + "Download", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + "upload": EboxSensorMetadata( + "Upload", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + "total": EboxSensorMetadata( + "Total", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -94,34 +154,19 @@ class EBoxSensor(SensorEntity): def __init__(self, ebox_data, sensor_type, name): """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] + metadata = SENSOR_TYPES[sensor_type] + self._attr_name = f"{name} {metadata.name}" + self._attr_unit_of_measurement = metadata.unit_of_measurement + self._attr_icon = metadata.icon self.ebox_data = ebox_data self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - @property def state(self): """Return the state of the sensor.""" return self._state - @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.""" - return self._icon - async def async_update(self): """Get the latest data from EBox and update the state.""" await self.ebox_data.async_update() From ba00c786b0855afe55b0b6c72b57b8398689e3b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Jul 2021 10:45:07 -0700 Subject: [PATCH 065/112] Correctly detect is not home (#53279) --- .../components/device_tracker/device_condition.py | 12 ++++++------ .../device_tracker/test_device_condition.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 714d6d7f016..afa899444f6 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -11,7 +11,6 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_TYPE, STATE_HOME, - STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry @@ -62,14 +61,15 @@ def async_condition_from_config( """Create a function to test a device condition.""" if config_validation: config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_home": - state = STATE_HOME - else: - state = STATE_NOT_HOME + + reverse = config[CONF_TYPE] == "is_not_home" @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + result = condition.state(hass, config[ATTR_ENTITY_ID], STATE_HOME) + if reverse: + result = not result + return result return test_is_state diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 2cd4aceeb07..7e3f79712c4 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -3,7 +3,7 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.device_tracker import DOMAIN -from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.const import STATE_HOME from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -119,7 +119,7 @@ async def test_if_state(hass, calls): assert len(calls) == 1 assert calls[0].data["some"] == "is_home - event - test_event1" - hass.states.async_set("device_tracker.entity", STATE_NOT_HOME) + hass.states.async_set("device_tracker.entity", "school") hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() From fd2f15b7c783f1006219e3c10aaee229924a60ba Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 21 Jul 2021 20:14:47 +0200 Subject: [PATCH 066/112] Add new unit constants (#53258) * Add new unit constant - MHz * Add new unit constants - precipitation (in, in/h) --- .../components/ambient_station/__init__.py | 23 ++++++++++++------- homeassistant/components/arwn/sensor.py | 7 +++++- homeassistant/components/huawei_lte/sensor.py | 5 ++-- homeassistant/const.py | 3 +++ 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 12f534eb8e9..d719f9b3728 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -28,6 +28,8 @@ from homeassistant.const import ( IRRADIATION_WATTS_PER_SQUARE_METER, LIGHT_LUX, PERCENTAGE, + PRECIPITATION_INCHES, + PRECIPITATION_INCHES_PER_HOUR, PRESSURE_INHG, SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, @@ -156,7 +158,7 @@ TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" TYPE_WINDSPEEDMPH = "windspeedmph" TYPE_YEARLYRAININ = "yearlyrainin" SENSOR_TYPES = { - TYPE_24HOURRAININ: ("24 Hr Rain", "in", SENSOR, None), + TYPE_24HOURRAININ: ("24 Hr Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_BAROMABSIN: ("Abs Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), TYPE_BAROMRELIN: ("Rel Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), TYPE_BATT10: ("Battery 10", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), @@ -172,11 +174,16 @@ SENSOR_TYPES = { TYPE_BATTOUT: ("Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), TYPE_BATT_CO2: ("CO2 Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), TYPE_CO2: ("co2", CONCENTRATION_PARTS_PER_MILLION, SENSOR, DEVICE_CLASS_CO2), - TYPE_DAILYRAININ: ("Daily Rain", "in", SENSOR, None), + TYPE_DAILYRAININ: ("Daily Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_DEWPOINT: ("Dew Point", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_EVENTRAININ: ("Event Rain", "in", SENSOR, None), + TYPE_EVENTRAININ: ("Event Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_FEELSLIKE: ("Feels Like", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_HOURLYRAININ: ("Hourly Rain Rate", "in/hr", SENSOR, None), + TYPE_HOURLYRAININ: ( + "Hourly Rain Rate", + PRECIPITATION_INCHES_PER_HOUR, + SENSOR, + None, + ), TYPE_HUMIDITY10: ("Humidity 10", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), TYPE_HUMIDITY1: ("Humidity 1", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), TYPE_HUMIDITY2: ("Humidity 2", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), @@ -191,7 +198,7 @@ SENSOR_TYPES = { TYPE_HUMIDITYIN: ("Humidity In", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), TYPE_LASTRAIN: ("Last Rain", None, SENSOR, DEVICE_CLASS_TIMESTAMP), TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_MONTHLYRAININ: ("Monthly Rain", "in", SENSOR, None), + TYPE_MONTHLYRAININ: ("Monthly Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_PM25_24H: ( "PM25 24h Avg", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -277,9 +284,9 @@ SENSOR_TYPES = { TYPE_TEMP9F: ("Temp 9", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), TYPE_TEMPF: ("Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), TYPE_TEMPINF: ("Inside Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TOTALRAININ: ("Lifetime Rain", "in", SENSOR, None), + TYPE_TOTALRAININ: ("Lifetime Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_UV: ("uv", "Index", SENSOR, None), - TYPE_WEEKLYRAININ: ("Weekly Rain", "in", SENSOR, None), + TYPE_WEEKLYRAININ: ("Weekly Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_WINDDIR: ("Wind Dir", DEGREE, SENSOR, None), TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", DEGREE, SENSOR, None), TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), @@ -288,7 +295,7 @@ SENSOR_TYPES = { TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, SENSOR, None), TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_YEARLYRAININ: ("Yearly Rain", "in", SENSOR, None), + TYPE_YEARLYRAININ: ("Yearly Rain", PRECIPITATION_INCHES, SENSOR, None), } CONFIG_SCHEMA = cv.deprecated(DOMAIN) diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 1c95911a19e..2300319f9a4 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( DEGREE, DEVICE_CLASS_TEMPERATURE, + PRECIPITATION_INCHES, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -44,7 +45,11 @@ def discover_sensors(topic, payload): if domain == "rain": if len(parts) >= 3 and parts[2] == "today": return ArwnSensor( - topic, "Rain Since Midnight", "since_midnight", "in", "mdi:water" + topic, + "Rain Since Midnight", + "since_midnight", + PRECIPITATION_INCHES, + "mdi:water", ) return ( ArwnSensor(topic + "/total", "Total Rainfall", "total", unit, "mdi:water"), diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 6554e69d76e..4340d5912c9 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_BYTES, DATA_RATE_BYTES_PER_SECOND, + FREQUENCY_MEGAHERTZ, PERCENTAGE, STATE_UNKNOWN, TIME_SECONDS, @@ -192,11 +193,11 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { ), (KEY_DEVICE_SIGNAL, "ltedlfreq"): SensorMeta( name="Downlink frequency", - formatter=lambda x: (round(int(x) / 10), "MHz"), + formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), ), (KEY_DEVICE_SIGNAL, "lteulfreq"): SensorMeta( name="Uplink frequency", - formatter=lambda x: (round(int(x) / 10), "MHz"), + formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), ), KEY_MONITORING_CHECK_NOTIFICATIONS: SensorMeta( exclude=re.compile( diff --git a/homeassistant/const.py b/homeassistant/const.py index 13b94b799db..0b8523bfa6f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -452,6 +452,7 @@ LENGTH_MILES: Final = "mi" # Frequency units FREQUENCY_HERTZ: Final = "Hz" +FREQUENCY_MEGAHERTZ: Final = "MHz" FREQUENCY_GIGAHERTZ: Final = "GHz" # Pressure units @@ -509,6 +510,8 @@ IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = "BTU/(h×ft²)" # Precipitation units PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +PRECIPITATION_INCHES: Final = "in" +PRECIPITATION_INCHES_PER_HOUR: Final = "in/h" # Concentration units CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" From 3eb3c2824c409feb9e2c68859fa6c04dd2574a7c Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 21 Jul 2021 14:52:17 -0400 Subject: [PATCH 067/112] Refactor goalzero (#53282) --- .../components/goalzero/binary_sensor.py | 28 +++++-------------- homeassistant/components/goalzero/const.py | 22 +++++++++------ homeassistant/components/goalzero/sensor.py | 8 +++--- homeassistant/components/goalzero/switch.py | 13 ++------- 4 files changed, 27 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 74776eb51b5..f9a110eff55 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -1,6 +1,6 @@ """Support for Goal Zero Yeti Sensors.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_NAME +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_NAME, CONF_NAME from . import YetiEntity from .const import BINARY_SENSOR_DICT, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN @@ -39,21 +39,12 @@ class YetiBinarySensor(YetiEntity, BinarySensorEntity): super().__init__(api, coordinator, name, server_unique_id) self._condition = sensor_name - - variable_info = BINARY_SENSOR_DICT[sensor_name] - self._condition_name = variable_info[0] - self._icon = variable_info[2] - self._device_class = variable_info[1] - - @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}" + 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)}" + ) @property def is_on(self) -> bool: @@ -61,8 +52,3 @@ class YetiBinarySensor(YetiEntity, BinarySensorEntity): if self.api.data: return self.api.data[self._condition] == 1 return False - - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return self._icon diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index da4a6ee4ad6..e9fed7dc52b 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, ELECTRIC_CURRENT_AMPERE, @@ -42,14 +43,19 @@ DATA_KEY_API = "api" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) BINARY_SENSOR_DICT = { - "backlight": ["Backlight", None, "mdi:clock-digital"], - "app_online": [ - "App Online", - DEVICE_CLASS_CONNECTIVITY, - None, - ], - "isCharging": ["Charging", DEVICE_CLASS_BATTERY_CHARGING, None], - "inputDetected": ["Input Detected", DEVICE_CLASS_POWER, None], + "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 = { diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index f64d6d772c8..594e1f0046b 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -44,13 +44,13 @@ class YetiSensor(YetiEntity): super().__init__(api, coordinator, name, server_unique_id) self._condition = sensor_name sensor = SENSOR_DICT[sensor_name] - self._attr_name = f"{name} {sensor.get(ATTR_NAME)}" - self._attr_unique_id = f"{self._server_unique_id}/{sensor_name}" - self._attr_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) - self._attr_entity_registry_enabled_default = sensor.get(ATTR_DEFAULT_ENABLED) 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_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: diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index 92808ef5f43..9d37bcb0b7b 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -38,17 +38,8 @@ class YetiSwitch(YetiEntity, SwitchEntity): """Initialize a Goal Zero Yeti switch.""" super().__init__(api, coordinator, name, server_unique_id) self._condition = switch_name - self._condition_name = SWITCH_DICT[switch_name] - - @property - def name(self) -> str: - """Return the name of the switch.""" - return f"{self._name} {self._condition_name}" - - @property - def unique_id(self) -> str: - """Return the unique id of the switch.""" - return f"{self._server_unique_id}/{self._condition}" + self._attr_name = f"{name} {SWITCH_DICT[switch_name]}" + self._attr_unique_id = f"{server_unique_id}/{switch_name}" @property def is_on(self) -> bool: From 6636e5b7372dc216d7802385f5519f775d656f17 Mon Sep 17 00:00:00 2001 From: cnico Date: Wed, 21 Jul 2021 21:35:44 +0200 Subject: [PATCH 068/112] Flipr integration (#46582) Co-authored-by: Franck Nijhof Co-authored-by: cnico <> --- CODEOWNERS | 1 + homeassistant/components/flipr/__init__.py | 90 ++++++++++ homeassistant/components/flipr/config_flow.py | 124 +++++++++++++ homeassistant/components/flipr/const.py | 10 ++ homeassistant/components/flipr/manifest.json | 12 ++ homeassistant/components/flipr/sensor.py | 90 ++++++++++ homeassistant/components/flipr/strings.json | 30 ++++ .../components/flipr/translations/en.json | 30 ++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/flipr/__init__.py | 1 + tests/components/flipr/test_config_flow.py | 166 ++++++++++++++++++ tests/components/flipr/test_init.py | 28 +++ tests/components/flipr/test_sensors.py | 92 ++++++++++ 15 files changed, 681 insertions(+) create mode 100644 homeassistant/components/flipr/__init__.py create mode 100644 homeassistant/components/flipr/config_flow.py create mode 100644 homeassistant/components/flipr/const.py create mode 100644 homeassistant/components/flipr/manifest.json create mode 100644 homeassistant/components/flipr/sensor.py create mode 100644 homeassistant/components/flipr/strings.json create mode 100644 homeassistant/components/flipr/translations/en.json create mode 100644 tests/components/flipr/__init__.py create mode 100644 tests/components/flipr/test_config_flow.py create mode 100644 tests/components/flipr/test_init.py create mode 100644 tests/components/flipr/test_sensors.py diff --git a/CODEOWNERS b/CODEOWNERS index 5fa884fda15..28dc16e7342 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -161,6 +161,7 @@ homeassistant/components/fireservicerota/* @cyberjunky homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff homeassistant/components/flick_electric/* @ZephireNZ +homeassistant/components/flipr/* @cnico homeassistant/components/flo/* @dmulcahey homeassistant/components/flock/* @fabaff homeassistant/components/flume/* @ChrisMandich @bdraco diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py new file mode 100644 index 00000000000..05bbd0d5449 --- /dev/null +++ b/homeassistant/components/flipr/__init__.py @@ -0,0 +1,90 @@ +"""The Flipr integration.""" +from datetime import timedelta +import logging + +from flipr_api import FliprAPIRestClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=60) + + +PLATFORMS = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flipr from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + coordinator = FliprDataUpdateCoordinator(hass, entry) + 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): + """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 FliprDataUpdateCoordinator(DataUpdateCoordinator): + """Class to hold Flipr data retrieval.""" + + def __init__(self, hass, entry): + """Initialize.""" + username = entry.data[CONF_EMAIL] + 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 + + super().__init__( + hass, + _LOGGER, + name=f"Flipr data measure for {self.flipr_id}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + return await self.hass.async_add_executor_job( + self.client.get_pool_measure_latest, self.flipr_id + ) + + +class FliprEntity(CoordinatorEntity): + """Implements a common class elements representing the Flipr component.""" + + def __init__(self, coordinator, flipr_id, info_type): + """Initialize Flipr sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{flipr_id}-{info_type}" + self._attr_device_info = { + "identifiers": {(DOMAIN, flipr_id)}, + "name": NAME, + "manufacturer": MANUFACTURER, + } + self.info_type = info_type + self.flipr_id = flipr_id diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py new file mode 100644 index 00000000000..b503281fed4 --- /dev/null +++ b/homeassistant/components/flipr/config_flow.py @@ -0,0 +1,124 @@ +"""Config flow for Flipr integration.""" +from __future__ import annotations + +import logging + +from flipr_api import FliprAPIRestClient +from requests.exceptions import HTTPError, Timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import CONF_FLIPR_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flipr.""" + + VERSION = 1 + + _username: str | None = None + _password: str | None = None + _flipr_id: str | None = None + _possible_flipr_ids: list[str] | None = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self._show_setup_form() + + self._username = user_input[CONF_EMAIL] + self._password = user_input[CONF_PASSWORD] + + errors = {} + if not self._flipr_id: + try: + flipr_ids = await self._authenticate_and_search_flipr() + except HTTPError: + errors["base"] = "invalid_auth" + except (Timeout, ConnectionError): + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(exception) + + if not errors and len(flipr_ids) == 0: + # No flipr_id found. Tell the user with an error message. + errors["base"] = "no_flipr_id_found" + + if errors: + return self._show_setup_form(errors) + + if len(flipr_ids) == 1: + self._flipr_id = flipr_ids[0] + else: + # If multiple flipr found (rare case), we ask the user to choose one in a select box. + # The user will have to run config_flow as many times as many fliprs he has. + self._possible_flipr_ids = flipr_ids + return await self.async_step_flipr_id() + + # Check if already configured + await self.async_set_unique_id(self._flipr_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._flipr_id, + data={ + CONF_EMAIL: self._username, + CONF_PASSWORD: self._password, + CONF_FLIPR_ID: self._flipr_id, + }, + ) + + def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + 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 + ) + + flipr_ids = await self.hass.async_add_executor_job(client.search_flipr_ids) + + return flipr_ids + + async def async_step_flipr_id(self, user_input=None): + """Handle the initial step.""" + if not user_input: + # Creation of a select with the proposal of flipr ids values found by API. + flipr_ids_for_form = {} + for flipr_id in self._possible_flipr_ids: + flipr_ids_for_form[flipr_id] = f"{flipr_id}" + + return self.async_show_form( + step_id="flipr_id", + data_schema=vol.Schema( + { + vol.Required(CONF_FLIPR_ID): vol.All( + vol.Coerce(str), vol.In(flipr_ids_for_form) + ) + } + ), + ) + + # Get chosen flipr_id. + self._flipr_id = user_input[CONF_FLIPR_ID] + + return await self.async_step_user( + { + CONF_EMAIL: self._username, + CONF_PASSWORD: self._password, + CONF_FLIPR_ID: self._flipr_id, + } + ) diff --git a/homeassistant/components/flipr/const.py b/homeassistant/components/flipr/const.py new file mode 100644 index 00000000000..d28353f4776 --- /dev/null +++ b/homeassistant/components/flipr/const.py @@ -0,0 +1,10 @@ +"""Constants for the Flipr integration.""" + +DOMAIN = "flipr" + +CONF_FLIPR_ID = "flipr_id" + +ATTRIBUTION = "Flipr Data" + +MANUFACTURER = "CTAC-TECH" +NAME = "Flipr" diff --git a/homeassistant/components/flipr/manifest.json b/homeassistant/components/flipr/manifest.json new file mode 100644 index 00000000000..330fea7de8b --- /dev/null +++ b/homeassistant/components/flipr/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "flipr", + "name": "Flipr", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flipr", + "requirements": [ + "flipr-api==1.4.1"], + "codeowners": [ + "@cnico" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py new file mode 100644 index 00000000000..427a668a72b --- /dev/null +++ b/homeassistant/components/flipr/sensor.py @@ -0,0 +1,90 @@ +"""Sensor platform for the Flipr's pool_sensor.""" +from datetime import datetime + +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 + +SENSORS = { + "chlorine": { + "unit": "mV", + "icon": "mdi:pool", + "name": "Chlorine", + "device_class": None, + }, + "ph": {"unit": None, "icon": "mdi:pool", "name": "pH", "device_class": None}, + "temperature": { + "unit": TEMP_CELSIUS, + "icon": None, + "name": "Water Temp", + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "date_time": { + "unit": None, + "icon": None, + "name": "Last Measured", + "device_class": DEVICE_CLASS_TIMESTAMP, + }, + "red_ox": { + "unit": "mV", + "icon": "mdi:pool", + "name": "Red OX", + "device_class": None, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + flipr_id = config_entry.data[CONF_FLIPR_ID] + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + sensors_list = [] + for sensor in SENSORS: + sensors_list.append(FliprSensor(coordinator, flipr_id, sensor)) + + async_add_entities(sensors_list, True) + + +class FliprSensor(FliprEntity, Entity): + """Sensor representing FliprSensor data.""" + + @property + def name(self): + """Return the name of the particular component.""" + return f"Flipr {self.flipr_id} {SENSORS[self.info_type]['name']}" + + @property + def state(self): + """State of the sensor.""" + state = self.coordinator.data[self.info_type] + if isinstance(state, datetime): + return state.isoformat() + return state + + @property + def device_class(self): + """Return the device class.""" + return SENSORS[self.info_type]["device_class"] + + @property + def icon(self): + """Return the icon.""" + return SENSORS[self.info_type]["icon"] + + @property + def unit_of_measurement(self): + """Return unit of measurement.""" + return SENSORS[self.info_type]["unit"] + + @property + def device_state_attributes(self): + """Return device attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json new file mode 100644 index 00000000000..55feaa691f7 --- /dev/null +++ b/homeassistant/components/flipr/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Flipr", + "description": "Connect using your Flipr account.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "flipr_id": { + "title": "Choose your Flipr", + "description": "Choose your Flipr ID in the list", + "data": { + "flipr_id": "Flipr ID" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/flipr/translations/en.json b/homeassistant/components/flipr/translations/en.json new file mode 100644 index 00000000000..017514e147c --- /dev/null +++ b/homeassistant/components/flipr/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "This Flipr is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first." + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "description": "Connect to your flipr account", + "title": "Flipr device" + }, + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Choose your flipr ID in the list", + "title": "Flipr device" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 943ca9cda74..b88d5639783 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = [ "faa_delays", "fireservicerota", "flick_electric", + "flipr", "flo", "flume", "flunearyou", diff --git a/requirements_all.txt b/requirements_all.txt index bb119c6a2c9..8bc57855b5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -617,6 +617,9 @@ fitbit==0.3.1 # homeassistant.components.fixer fixerio==1.0.0a0 +# homeassistant.components.flipr +flipr-api==1.4.1 + # homeassistant.components.flux_led flux_led==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22a017442eb..de3bbfd46f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -338,6 +338,9 @@ faadelays==0.0.7 # homeassistant.components.feedreader feedparser==6.0.2 +# homeassistant.components.flipr +flipr-api==1.4.1 + # homeassistant.components.homekit fnvhash==0.1.0 diff --git a/tests/components/flipr/__init__.py b/tests/components/flipr/__init__.py new file mode 100644 index 00000000000..26767261866 --- /dev/null +++ b/tests/components/flipr/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flipr integration.""" diff --git a/tests/components/flipr/test_config_flow.py b/tests/components/flipr/test_config_flow.py new file mode 100644 index 00000000000..66410938aab --- /dev/null +++ b/tests/components/flipr/test_config_flow.py @@ -0,0 +1,166 @@ +"""Test the Flipr config flow.""" +from unittest.mock import patch + +import pytest +from requests.exceptions import HTTPError, Timeout + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + + +@pytest.fixture(name="mock_setup") +def mock_setups(): + """Prevent setup.""" + with patch( + "homeassistant.components.flipr.async_setup_entry", + return_value=True, + ): + yield + + +async def test_show_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"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + +async def test_invalid_credential(hass, mock_setup): + """Test invalid credential.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", side_effect=HTTPError() + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "bad_login", + CONF_PASSWORD: "bad_pass", + CONF_FLIPR_ID: "", + }, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_nominal_case(hass, mock_setup): + """Test valid login form.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + return_value=["flipid"], + ) as mock_flipr_client: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "flipid", + }, + ) + await hass.async_block_till_done() + + assert len(mock_flipr_client.mock_calls) == 1 + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "flipid" + assert result["data"] == { + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "flipid", + } + + +async def test_multiple_flip_id(hass, mock_setup): + """Test multiple flipr id adding a config step.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + return_value=["FLIP1", "FLIP2"], + ) as mock_flipr_client: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "flipr_id" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_FLIPR_ID: "FLIP2"}, + ) + + assert len(mock_flipr_client.mock_calls) == 1 + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "FLIP2" + assert result["data"] == { + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "FLIP2", + } + + +async def test_no_flip_id(hass, mock_setup): + """Test no flipr id found.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + return_value=[], + ) as mock_flipr_client: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["step_id"] == "user" + assert result["type"] == "form" + assert result["errors"] == {"base": "no_flipr_id_found"} + + assert len(mock_flipr_client.mock_calls) == 1 + + +async def test_http_errors(hass, mock_setup): + """Test HTTP Errors.""" + with patch("flipr_api.FliprAPIRestClient.search_flipr_ids", side_effect=Timeout()): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "nada", + CONF_PASSWORD: "nada", + CONF_FLIPR_ID: "", + }, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + side_effect=Exception("Bad request Boy :) --"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "nada", + CONF_PASSWORD: "nada", + CONF_FLIPR_ID: "", + }, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py new file mode 100644 index 00000000000..08487c18a46 --- /dev/null +++ b/tests/components/flipr/test_init.py @@ -0,0 +1,28 @@ +"""Tests for init methods.""" +from unittest.mock import patch + +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant): + """Test unload entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "FLIP1", + }, + unique_id="123456", + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.flipr.FliprAPIRestClient"): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/flipr/test_sensors.py b/tests/components/flipr/test_sensors.py new file mode 100644 index 00000000000..244ec61507c --- /dev/null +++ b/tests/components/flipr/test_sensors.py @@ -0,0 +1,92 @@ +"""Test the Flipr sensor and binary sensor.""" +from datetime import datetime +from unittest.mock import patch + +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONF_EMAIL, + CONF_PASSWORD, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + +# Data for the mocked object returned via flipr_api client. +MOCK_DATE_TIME = datetime(2021, 2, 15, 9, 10, 32, tzinfo=dt_util.UTC) +MOCK_FLIPR_MEASURE = { + "temperature": 10.5, + "ph": 7.03, + "chlorine": 0.23654886, + "red_ox": 657.58, + "date_time": MOCK_DATE_TIME, + "ph_status": "TooLow", + "chlorine_status": "Medium", +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the creation and values of the Flipr sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test_entry_unique_id", + data={ + CONF_EMAIL: "toto@toto.com", + CONF_PASSWORD: "myPassword", + CONF_FLIPR_ID: "myfliprid", + }, + ) + + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for sensors + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "my_random_entity_id", + suggested_object_id="sensor.flipr_myfliprid_chlorine", + disabled_by=None, + ) + + with patch( + "flipr_api.FliprAPIRestClient.get_pool_measure_latest", + return_value=MOCK_FLIPR_MEASURE, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.flipr_myfliprid_ph") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:pool" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.state == "7.03" + + state = hass.states.get("sensor.flipr_myfliprid_water_temp") + assert state + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is TEMP_CELSIUS + assert state.state == "10.5" + + state = hass.states.get("sensor.flipr_myfliprid_last_measured") + assert state + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.state == "2021-02-15T09:10:32+00:00" + + state = hass.states.get("sensor.flipr_myfliprid_red_ox") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:pool" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.state == "657.58" + + state = hass.states.get("sensor.flipr_myfliprid_chlorine") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:pool" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.state == "0.23654886" From 8d9345c40717723484a7634ec381d8e5b0b72fa0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 21 Jul 2021 14:18:08 -0600 Subject: [PATCH 069/112] Add missing type annotations to Airvisual (#52615) --- .strict-typing | 1 + .../components/airvisual/__init__.py | 67 ++++++++++++------- .../components/airvisual/config_flow.py | 52 +++++++++----- homeassistant/components/airvisual/sensor.py | 42 ++++++++++-- mypy.ini | 11 +++ 5 files changed, 125 insertions(+), 48 deletions(-) diff --git a/.strict-typing b/.strict-typing index 735d9ca4a64..ed6062d19f7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -9,6 +9,7 @@ homeassistant.components.actiontec.* homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* +homeassistant.components.airvisual.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.amazon_polly.* diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 89963bff623..015c913b815 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1,6 +1,10 @@ """The airvisual component.""" +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping from datetime import timedelta from math import ceil +from typing import Any, Dict, cast from pyairvisual import CloudAPI, NodeSamba from pyairvisual.errors import ( @@ -10,6 +14,7 @@ from pyairvisual.errors import ( NodeProError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, @@ -20,7 +25,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, CONF_STATE, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import ( aiohttp_client, @@ -57,11 +62,8 @@ CONFIG_SCHEMA = cv.deprecated(DOMAIN) @callback -def async_get_geography_id(geography_dict): +def async_get_geography_id(geography_dict: Mapping[str, Any]) -> str: """Generate a unique ID from a geography dict.""" - if not geography_dict: - return - if CONF_CITY in geography_dict: return ", ".join( ( @@ -76,7 +78,9 @@ def async_get_geography_id(geography_dict): @callback -def async_get_cloud_api_update_interval(hass, api_key, num_consumers): +def async_get_cloud_api_update_interval( + hass: HomeAssistant, api_key: str, num_consumers: int +) -> timedelta: """Get a leveled scan interval for a particular cloud API key. This will shift based on the number of active consumers, thus keeping the user @@ -97,18 +101,22 @@ def async_get_cloud_api_update_interval(hass, api_key, num_consumers): @callback -def async_get_cloud_coordinators_by_api_key(hass, api_key): +def async_get_cloud_coordinators_by_api_key( + hass: HomeAssistant, api_key: str +) -> list[DataUpdateCoordinator]: """Get all DataUpdateCoordinator objects related to a particular API key.""" coordinators = [] for entry_id, coordinator in hass.data[DOMAIN][DATA_COORDINATOR].items(): config_entry = hass.config_entries.async_get_entry(entry_id) - if config_entry.data.get(CONF_API_KEY) == api_key: + if config_entry and config_entry.data.get(CONF_API_KEY) == api_key: coordinators.append(coordinator) return coordinators @callback -def async_sync_geo_coordinator_update_intervals(hass, api_key): +def async_sync_geo_coordinator_update_intervals( + hass: HomeAssistant, api_key: str +) -> None: """Sync the update interval for geography-based data coordinators (by API key).""" coordinators = async_get_cloud_coordinators_by_api_key(hass, api_key) @@ -129,7 +137,9 @@ def async_sync_geo_coordinator_update_intervals(hass, api_key): @callback -def _standardize_geography_config_entry(hass, config_entry): +def _standardize_geography_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Ensure that geography config entries have appropriate properties.""" entry_updates = {} @@ -162,9 +172,11 @@ def _standardize_geography_config_entry(hass, config_entry): @callback -def _standardize_node_pro_config_entry(hass, config_entry): +def _standardize_node_pro_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Ensure that Node/Pro config entries have appropriate properties.""" - entry_updates = {} + entry_updates: dict[str, Any] = {} if CONF_INTEGRATION_TYPE not in config_entry.data: # If the config entry data doesn't contain the integration type, add it: @@ -179,7 +191,7 @@ def _standardize_node_pro_config_entry(hass, config_entry): hass.config_entries.async_update_entry(config_entry, **entry_updates) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up AirVisual as config entry.""" hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}, DATA_LISTENER: {}}) @@ -189,7 +201,7 @@ async def async_setup_entry(hass, config_entry): websession = aiohttp_client.async_get_clientsession(hass) cloud_api = CloudAPI(config_entry.data[CONF_API_KEY], session=websession) - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" if CONF_CITY in config_entry.data: api_coro = cloud_api.air_quality.city( @@ -204,7 +216,8 @@ async def async_setup_entry(hass, config_entry): ) try: - return await api_coro + data = await api_coro + return cast(Dict[str, Any], data) except (InvalidKeyError, KeyExpiredError) as ex: raise ConfigEntryAuthFailed from ex except AirVisualError as err: @@ -242,13 +255,14 @@ async def async_setup_entry(hass, config_entry): _standardize_node_pro_config_entry(hass, config_entry) - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" try: async with NodeSamba( config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PASSWORD] ) as node: - return await node.async_get_latest_measurements() + data = await node.async_get_latest_measurements() + return cast(Dict[str, Any], data) except NodeProError as err: raise UpdateFailed(f"Error while retrieving data: {err}") from err @@ -275,7 +289,7 @@ async def async_setup_entry(hass, config_entry): return True -async def async_migrate_entry(hass, config_entry): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate an old config entry.""" version = config_entry.version @@ -317,7 +331,7 @@ async def async_migrate_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload an AirVisual config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS @@ -338,7 +352,7 @@ async def async_unload_entry(hass, config_entry): return unload_ok -async def async_reload_entry(hass, config_entry): +async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(config_entry.entry_id) @@ -346,16 +360,19 @@ async def async_reload_entry(hass, config_entry): class AirVisualEntity(CoordinatorEntity): """Define a generic AirVisual entity.""" - def __init__(self, coordinator): + def __init__(self, coordinator: DataUpdateCoordinator) -> None: """Initialize.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - async def async_added_to_hass(self): + self._attr_extra_state_attributes: MutableMapping[str, Any] = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION + } + + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback - def update(): + def update() -> None: """Update the state.""" self.update_from_latest_data() self.async_write_ha_state() @@ -365,6 +382,6 @@ class AirVisualEntity(CoordinatorEntity): self.update_from_latest_data() @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" raise NotImplementedError diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index ef7873a31b1..971dee161cb 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -1,4 +1,6 @@ """Define a config flow manager for AirVisual.""" +from __future__ import annotations + import asyncio from pyairvisual import CloudAPI, NodeSamba @@ -11,6 +13,7 @@ from pyairvisual.errors import ( import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import ( CONF_API_KEY, CONF_IP_ADDRESS, @@ -21,6 +24,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from . import async_get_geography_id @@ -64,13 +68,13 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" - self._entry_data_for_reauth = None - self._geo_id = None + self._entry_data_for_reauth: dict[str, str] = {} + self._geo_id: str | None = None @property - def geography_coords_schema(self): + def geography_coords_schema(self) -> vol.Schema: """Return the data schema for the cloud API.""" return API_KEY_DATA_SCHEMA.extend( { @@ -83,7 +87,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def _async_finish_geography(self, user_input, integration_type): + async def _async_finish_geography( + self, user_input: dict[str, str], integration_type: str + ) -> FlowResult: """Validate a Cloud API key.""" websession = aiohttp_client.async_get_clientsession(self.hass) cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) @@ -142,25 +148,29 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={**user_input, CONF_INTEGRATION_TYPE: integration_type}, ) - async def _async_init_geography(self, user_input, integration_type): + async def _async_init_geography( + self, user_input: dict[str, str], integration_type: str + ) -> FlowResult: """Handle the initialization of the integration via the cloud API.""" self._geo_id = async_get_geography_id(user_input) await self._async_set_unique_id(self._geo_id) self._abort_if_unique_id_configured() return await self._async_finish_geography(user_input, integration_type) - async def _async_set_unique_id(self, unique_id): + async def _async_set_unique_id(self, unique_id: str) -> None: """Set the unique ID of the config flow and abort if it already exists.""" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Define the config flow to handle options.""" return AirVisualOptionsFlowHandler(config_entry) - async def async_step_geography_by_coords(self, user_input=None): + async def async_step_geography_by_coords( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the cloud API based on latitude/longitude.""" if not user_input: return self.async_show_form( @@ -171,7 +181,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input, INTEGRATION_TYPE_GEOGRAPHY_COORDS ) - async def async_step_geography_by_name(self, user_input=None): + async def async_step_geography_by_name( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the cloud API based on city/state/country.""" if not user_input: return self.async_show_form( @@ -182,7 +194,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input, INTEGRATION_TYPE_GEOGRAPHY_NAME ) - async def async_step_node_pro(self, user_input=None): + async def async_step_node_pro( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the integration with a Node/Pro.""" if not user_input: return self.async_show_form(step_id="node_pro", data_schema=NODE_PRO_SCHEMA) @@ -208,13 +222,15 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO}, ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: """Handle configuration by re-auth.""" self._entry_data_for_reauth = data self._geo_id = async_get_geography_id(data) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle re-auth completion.""" if not user_input: return self.async_show_form( @@ -227,7 +243,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): conf, self._entry_data_for_reauth[CONF_INTEGRATION_TYPE] ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return self.async_show_form( @@ -244,11 +262,13 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class AirVisualOptionsFlowHandler(config_entries.OptionsFlow): """Handle an AirVisual options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index d4b988a0ddc..693742217e5 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,5 +1,8 @@ """Support for AirVisual air quality sensors.""" +from __future__ import annotations + from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -18,7 +21,10 @@ from homeassistant.const import ( PERCENTAGE, TEMP_CELSIUS, ) -from homeassistant.core import callback +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 DataUpdateCoordinator from . import AirVisualEntity from .const import ( @@ -141,10 +147,15 @@ POLLUTANT_UNITS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up AirVisual sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + sensors: list[AirVisualGeographySensor | AirVisualNodeProSensor] if config_entry.data[CONF_INTEGRATION_TYPE] in [ INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_GEOGRAPHY_NAME, @@ -174,7 +185,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AirVisualGeographySensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to geography data via the Cloud API.""" - def __init__(self, coordinator, config_entry, kind, name, icon, unit, locale): + def __init__( + self, + coordinator: DataUpdateCoordinator, + config_entry: ConfigEntry, + kind: str, + name: str, + icon: str, + unit: str | None, + locale: str, + ) -> None: """Initialize.""" super().__init__(coordinator) @@ -203,7 +223,7 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): return super().available and self.coordinator.data["current"]["pollution"] @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" try: data = self.coordinator.data["current"]["pollution"] @@ -260,7 +280,15 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to a Node/Pro unit.""" - def __init__(self, coordinator, kind, name, device_class, icon, unit): + def __init__( + self, + coordinator: DataUpdateCoordinator, + kind: str, + name: str, + device_class: str | None, + icon: str | None, + unit: str, + ) -> None: """Initialize.""" super().__init__(coordinator) @@ -274,7 +302,7 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): self._kind = kind @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" return { "identifiers": {(DOMAIN, self.coordinator.data["serial_number"])}, @@ -288,7 +316,7 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): } @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" if self._kind == SENSOR_KIND_AQI: if self.coordinator.data["settings"]["is_aqi_usa"]: diff --git a/mypy.ini b/mypy.ini index 22cd7f4a1e0..67f631f05bf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -110,6 +110,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airvisual.*] +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.aladdin_connect.*] check_untyped_defs = true disallow_incomplete_defs = true From f3d95501d90f90ac0f2ecd75afd0104a526121eb Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 21 Jul 2021 23:15:47 +0200 Subject: [PATCH 070/112] Add refresh after turning switch on or off and type annotations to ezviz (#52469) --- homeassistant/components/ezviz/switch.py | 68 ++++++++++++++++-------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 00230a3ac2d..9949dc18b23 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -1,26 +1,34 @@ """Support for Ezviz Switch sensors.""" +from __future__ import annotations + import logging +from typing import Any from pyezviz.constants import DeviceSwitchType +from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity +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 homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER +from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -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 Ezviz switch based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] switch_entities = [] - supported_switches = [] - - for switches in DeviceSwitchType: - supported_switches.append(switches.value) - - supported_switches = set(supported_switches) + supported_switches = {switches.value for switches in DeviceSwitchType} for idx, camera in enumerate(coordinator.data): if not camera.get("switches"): @@ -36,7 +44,11 @@ async def async_setup_entry(hass, entry, async_add_entities): class EzvizSwitch(CoordinatorEntity, SwitchEntity): """Representation of a Ezviz sensor.""" - def __init__(self, coordinator, idx, switch): + coordinator: EzvizDataUpdateCoordinator + + def __init__( + self, coordinator: EzvizDataUpdateCoordinator, idx: int, switch: str + ) -> None: """Initialize the switch.""" super().__init__(coordinator) self._idx = idx @@ -47,34 +59,48 @@ class EzvizSwitch(CoordinatorEntity, SwitchEntity): self._device_class = DEVICE_CLASS_SWITCH @property - def name(self): + def name(self) -> str: """Return the name of the Ezviz switch.""" - return f"{self._camera_name}.{DeviceSwitchType(self._name).name}" + return f"{DeviceSwitchType(self._name).name}" @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the switch.""" return self.coordinator.data[self._idx]["switches"][self._name] @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of this switch.""" return f"{self._serial}_{self._sensor_name}" - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Change a device switch on the camera.""" - _LOGGER.debug("Set EZVIZ Switch '%s' to on", self._name) + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, self._serial, self._name, 1 + ) - self.coordinator.ezviz_client.switch_status(self._serial, self._name, 1) + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError("Failed to turn on switch {self._name}") from err - def turn_off(self, **kwargs): + if update_ok: + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: """Change a device switch on the camera.""" - _LOGGER.debug("Set EZVIZ Switch '%s' to off", self._name) + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, self._serial, self._name, 0 + ) - self.coordinator.ezviz_client.switch_status(self._serial, self._name, 0) + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError(f"Failed to turn off switch {self._name}") from err + + if update_ok: + await self.coordinator.async_request_refresh() @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" return { "identifiers": {(DOMAIN, self._serial)}, @@ -85,6 +111,6 @@ class EzvizSwitch(CoordinatorEntity, SwitchEntity): } @property - def device_class(self): + def device_class(self) -> str: """Device class for the sensor.""" return self._device_class From cfd69de5a75efabfbe41518c9616339751d8ddb8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 21 Jul 2021 23:28:22 +0200 Subject: [PATCH 071/112] Upgrade PyNaCl to 1.4.0 (#53287) --- homeassistant/components/mobile_app/manifest.json | 2 +- homeassistant/components/owntracks/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/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index d850d9ab469..a59f9bf28cf 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -3,7 +3,7 @@ "name": "Mobile App", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mobile_app", - "requirements": ["PyNaCl==1.3.0", "emoji==1.2.0"], + "requirements": ["PyNaCl==1.4.0", "emoji==1.2.0"], "dependencies": ["http", "webhook", "person", "tag", "websocket_api"], "after_dependencies": ["cloud", "camera", "notify"], "codeowners": ["@robbiet480"], diff --git a/homeassistant/components/owntracks/manifest.json b/homeassistant/components/owntracks/manifest.json index 9e83e5b4ec4..40dbb7d569c 100644 --- a/homeassistant/components/owntracks/manifest.json +++ b/homeassistant/components/owntracks/manifest.json @@ -3,7 +3,7 @@ "name": "OwnTracks", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/owntracks", - "requirements": ["PyNaCl==1.3.0"], + "requirements": ["PyNaCl==1.4.0"], "dependencies": ["webhook"], "after_dependencies": ["mqtt", "cloud"], "codeowners": [], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 62b7c5e95d5..f39ad5e61a0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,5 @@ PyJWT==1.7.1 -PyNaCl==1.3.0 +PyNaCl==1.4.0 aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8bc57855b5c..0644dccc04b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,7 +39,7 @@ PyMata==2.20 # homeassistant.components.mobile_app # homeassistant.components.owntracks -PyNaCl==1.3.0 +PyNaCl==1.4.0 # homeassistant.auth.mfa_modules.totp # homeassistant.components.homekit diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de3bbfd46f7..53d5ccf8a57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -14,7 +14,7 @@ PyFlick==0.0.2 # homeassistant.components.mobile_app # homeassistant.components.owntracks -PyNaCl==1.3.0 +PyNaCl==1.4.0 # homeassistant.auth.mfa_modules.totp # homeassistant.components.homekit From 34b1ab5f5cd7fcbd89f50ee1a7bce830f99cef99 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 21 Jul 2021 23:29:27 +0200 Subject: [PATCH 072/112] Upgrade to async-upnp-client==0.19.1 (#53288) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- 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 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 87730aa1316..d11b32a6dd5 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.0"], + "requirements": ["async-upnp-client==0.19.1"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index faadfac5c0c..432686d9027 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.0" + "async-upnp-client==0.19.1" ], "dependencies": ["network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index b252f5082cb..810a53c9e28 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.0"], + "requirements": ["async-upnp-client==0.19.1"], "dependencies": ["ssdp"], "codeowners": ["@StevenLooman"], "ssdp": [ diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f39ad5e61a0..19682407737 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.0 +async-upnp-client==0.19.1 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0644dccc04b..03bad6a7778 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -307,7 +307,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.0 +async-upnp-client==0.19.1 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53d5ccf8a57..e8fbf70e72c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -199,7 +199,7 @@ arcam-fmj==0.7.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.0 +async-upnp-client==0.19.1 # homeassistant.components.aurora auroranoaa==0.0.2 From 84c482441d5669de875598538d2a45af05a40778 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jul 2021 11:29:41 -1000 Subject: [PATCH 073/112] Use None instead of STATE_UNKNOWN in template lock (#53286) --- homeassistant/components/template/lock.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 55a568ed3c2..51431d133f7 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, STATE_LOCKED, STATE_ON, - STATE_UNKNOWN, STATE_UNLOCKED, ) from homeassistant.core import callback @@ -145,7 +144,7 @@ class TemplateLock(TemplateEntity, LockEntity): self._state = result.lower() return - self._state = STATE_UNKNOWN + self._state = None async def async_added_to_hass(self): """Register callbacks.""" From 583deada83d34acb5c23c99cb0e84f0bc030e523 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Wed, 21 Jul 2021 23:36:57 +0200 Subject: [PATCH 074/112] Add type annotations for Netatmo (#52811) --- .strict-typing | 1 + homeassistant/components/netatmo/__init__.py | 14 +-- homeassistant/components/netatmo/api.py | 6 +- homeassistant/components/netatmo/camera.py | 88 ++++++++++-------- homeassistant/components/netatmo/climate.py | 90 ++++++++++++------- .../components/netatmo/config_flow.py | 27 +++--- homeassistant/components/netatmo/const.py | 3 +- .../components/netatmo/data_handler.py | 29 +++--- .../components/netatmo/device_trigger.py | 18 ++-- homeassistant/components/netatmo/helper.py | 4 +- homeassistant/components/netatmo/light.py | 34 +++++-- .../components/netatmo/manifest.json | 2 +- .../components/netatmo/media_source.py | 13 +-- .../components/netatmo/netatmo_entity_base.py | 23 +++-- homeassistant/components/netatmo/sensor.py | 67 ++++++++------ homeassistant/components/netatmo/webhook.py | 15 ++-- mypy.ini | 14 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/mypy_config.py | 1 - tests/components/netatmo/test_select.py | 12 +-- 21 files changed, 288 insertions(+), 177 deletions(-) diff --git a/.strict-typing b/.strict-typing index ed6062d19f7..98a96f98fb0 100644 --- a/.strict-typing +++ b/.strict-typing @@ -60,6 +60,7 @@ homeassistant.components.mailbox.* homeassistant.components.media_player.* homeassistant.components.mysensors.* homeassistant.components.nam.* +homeassistant.components.netatmo.* homeassistant.components.network.* homeassistant.components.no_ip.* homeassistant.components.notify.* diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index d92e50107c9..edb8837fd18 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -1,4 +1,6 @@ """The Netatmo integration.""" +from __future__ import annotations + import logging import secrets @@ -67,7 +69,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Netatmo component.""" hass.data[DOMAIN] = { DATA_PERSONS: {}, @@ -121,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - async def unregister_webhook(_): + async def unregister_webhook(_: None) -> None: if CONF_WEBHOOK_ID not in entry.data: return _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID]) @@ -138,7 +140,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "No webhook to be dropped for %s", entry.data[CONF_WEBHOOK_ID] ) - async def register_webhook(event): + async def register_webhook(_: None) -> None: if CONF_WEBHOOK_ID not in entry.data: data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} hass.config_entries.async_update_entry(entry, data=data) @@ -175,7 +177,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_handle_webhook, ) - async def handle_event(event): + async def handle_event(event: dict) -> None: """Handle webhook events.""" if event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_ACTIVATION: if activation_listener is not None: @@ -219,7 +221,7 @@ async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> async_dispatcher_send(hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}") -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if CONF_WEBHOOK_ID in entry.data: webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) @@ -236,7 +238,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Cleanup when entry is removed.""" if ( CONF_WEBHOOK_ID in entry.data diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index 19dfdac359b..e13032dc399 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -1,4 +1,6 @@ """API for Netatmo bound to HASS OAuth.""" +from typing import cast + from aiohttp import ClientSession import pyatmo @@ -17,8 +19,8 @@ class AsyncConfigEntryNetatmoAuth(pyatmo.auth.AbstractAsyncAuth): super().__init__(websession) self._oauth_session = oauth_session - async def async_get_access_token(self): + async def async_get_access_token(self) -> str: """Return a valid access token for Netatmo API.""" if not self._oauth_session.valid_token: await self._oauth_session.async_ensure_token_valid() - return self._oauth_session.token["access_token"] + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 798156b7411..4ae40181a93 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -1,15 +1,20 @@ """Support for the Netatmo cameras.""" +from __future__ import annotations + import logging +from typing import Any, cast import aiohttp import pyatmo import voluptuous as vol from homeassistant.components.camera import SUPPORT_STREAM, Camera -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_CAMERA_LIGHT_MODE, @@ -31,11 +36,12 @@ from .const import ( SERVICE_SET_PERSON_AWAY, SERVICE_SET_PERSONS_HOME, SIGNAL_NAME, + UNKNOWN, WEBHOOK_LIGHT_MODE, WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import CAMERA_DATA_CLASS_NAME +from .data_handler import CAMERA_DATA_CLASS_NAME, NetatmoDataHandler from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -43,7 +49,9 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_QUALITY = "high" -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 Netatmo camera platform.""" if "access_camera" not in entry.data["token"]["scope"]: _LOGGER.info( @@ -108,12 +116,12 @@ class NetatmoCamera(NetatmoBase, Camera): def __init__( self, - data_handler, - camera_id, - camera_type, - home_id, - quality, - ): + data_handler: NetatmoDataHandler, + camera_id: str, + camera_type: str, + home_id: str, + quality: str, + ) -> None: """Set up for access to the Netatmo camera images.""" Camera.__init__(self) super().__init__(data_handler) @@ -124,17 +132,19 @@ class NetatmoCamera(NetatmoBase, Camera): self._id = camera_id self._home_id = home_id - self._device_name = self._data.get_camera(camera_id=camera_id).get("name") + self._device_name = self._data.get_camera(camera_id=camera_id).get( + "name", UNKNOWN + ) self._attr_name = f"{MANUFACTURER} {self._device_name}" self._model = camera_type self._attr_unique_id = f"{self._id}-{self._model}" self._quality = quality - self._vpnurl = None - self._localurl = None - self._status = None - self._sd_status = None - self._alim_status = None - self._is_local = None + self._vpnurl: str | None = None + self._localurl: str | None = None + self._status: str | None = None + self._sd_status: str | None = None + self._alim_status: str | None = None + self._is_local: str | None = None self._light_state = None async def async_added_to_hass(self) -> None: @@ -153,7 +163,7 @@ class NetatmoCamera(NetatmoBase, Camera): self.hass.data[DOMAIN][DATA_CAMERAS][self._id] = self._device_name @callback - def handle_event(self, event): + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -179,7 +189,15 @@ class NetatmoCamera(NetatmoBase, Camera): self.async_write_ha_state() return - async def async_camera_image(self): + @property + def _data(self) -> pyatmo.AsyncCameraData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncCameraData, + self.data_handler.data[self._data_classes[0]["name"]], + ) + + async def async_camera_image(self) -> bytes | None: """Return a still image response from the camera.""" try: return await self._data.async_get_live_snapshot(camera_id=self._id) @@ -194,43 +212,43 @@ class NetatmoCamera(NetatmoBase, Camera): return None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return bool(self._alim_status == "on" or self._status == "disconnected") @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" return SUPPORT_STREAM @property - def brand(self): + def brand(self) -> str: """Return the camera brand.""" return MANUFACTURER @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return bool(self._status == "on") @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return self.is_streaming - async def async_turn_off(self): + async def async_turn_off(self) -> None: """Turn off camera.""" await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, monitoring="off" ) - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn on camera.""" await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, monitoring="on" ) - async def stream_source(self): + async def stream_source(self) -> str: """Return the stream source.""" url = "{0}/live/files/{1}/index.m3u8" if self._localurl: @@ -238,12 +256,12 @@ class NetatmoCamera(NetatmoBase, Camera): return url.format(self._vpnurl, self._quality) @property - def model(self): + def model(self) -> str: """Return the camera model.""" return MODELS[self._model] @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" camera = self._data.get_camera(self._id) self._vpnurl, self._localurl = self._data.camera_urls(self._id) @@ -275,7 +293,7 @@ class NetatmoCamera(NetatmoBase, Camera): } ) - def process_events(self, events): + def process_events(self, events: dict) -> dict: """Add meta data to events.""" for event in events.values(): if "video_id" not in event: @@ -290,9 +308,9 @@ class NetatmoCamera(NetatmoBase, Camera): ] = f"{self._vpnurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8" return events - async def _service_set_persons_home(self, **kwargs): + async def _service_set_persons_home(self, **kwargs: Any) -> None: """Service to change current home schedule.""" - persons = kwargs.get(ATTR_PERSONS) + persons = kwargs.get(ATTR_PERSONS, {}) person_ids = [] for person in persons: for pid, data in self._data.persons.items(): @@ -304,7 +322,7 @@ class NetatmoCamera(NetatmoBase, Camera): ) _LOGGER.debug("Set %s as at home", persons) - async def _service_set_person_away(self, **kwargs): + async def _service_set_person_away(self, **kwargs: Any) -> None: """Service to mark a person as away or set the home as empty.""" person = kwargs.get(ATTR_PERSON) person_id = None @@ -327,10 +345,10 @@ class NetatmoCamera(NetatmoBase, Camera): ) _LOGGER.debug("Set home as empty") - async def _service_set_camera_light(self, **kwargs): + async def _service_set_camera_light(self, **kwargs: Any) -> None: """Service to set light mode.""" - mode = kwargs.get(ATTR_CAMERA_LIGHT_MODE) - _LOGGER.debug("Turn %s camera light for '%s'", mode, self.name) + mode = str(kwargs.get(ATTR_CAMERA_LIGHT_MODE)) + _LOGGER.debug("Turn %s camera light for '%s'", mode, self._attr_name) await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index c041370638c..ccc5816a28b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import pyatmo import voluptuous as vol @@ -19,6 +20,7 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, @@ -26,11 +28,13 @@ from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import async_get_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_HEATING_POWER_REQUEST, @@ -49,7 +53,11 @@ from .const import ( SERVICE_SET_SCHEDULE, SIGNAL_NAME, ) -from .data_handler import HOMEDATA_DATA_CLASS_NAME, HOMESTATUS_DATA_CLASS_NAME +from .data_handler import ( + HOMEDATA_DATA_CLASS_NAME, + HOMESTATUS_DATA_CLASS_NAME, + NetatmoDataHandler, +) from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -106,8 +114,12 @@ DEFAULT_MAX_TEMP = 30 NA_THERM = "NATherm1" NA_VALVE = "NRV" +SUGGESTED_AREA = "suggested_area" -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 Netatmo energy platform.""" data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] @@ -163,7 +175,9 @@ async def async_setup_entry(hass, entry, async_add_entities): class NetatmoThermostat(NetatmoBase, ClimateEntity): """Representation a Netatmo thermostat.""" - def __init__(self, data_handler, home_id, room_id): + def __init__( + self, data_handler: NetatmoDataHandler, home_id: str, room_id: str + ) -> None: """Initialize the sensor.""" ClimateEntity.__init__(self) super().__init__(data_handler) @@ -189,29 +203,29 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self._home_status = self.data_handler.data[self._home_status_class] self._room_status = self._home_status.rooms[room_id] - self._room_data = self._data.rooms[home_id][room_id] + self._room_data: dict = self._data.rooms[home_id][room_id] - self._model = NA_VALVE - for module in self._room_data.get("module_ids"): + self._model: str = NA_VALVE + for module in self._room_data.get("module_ids", []): if self._home_status.thermostats.get(module): self._model = NA_THERM break self._device_name = self._data.rooms[home_id][room_id]["name"] self._attr_name = f"{MANUFACTURER} {self._device_name}" - self._current_temperature = None - self._target_temperature = None - self._preset = None - self._away = None + self._current_temperature: float | None = None + self._target_temperature: float | None = None + self._preset: str | None = None + self._away: bool | None = None self._operation_list = [HVAC_MODE_AUTO, HVAC_MODE_HEAT] self._support_flags = SUPPORT_FLAGS - self._hvac_mode = None + self._hvac_mode: str = HVAC_MODE_AUTO self._battery_level = None - self._connected = None + self._connected: bool | None = None - self._away_temperature = None - self._hg_temperature = None - self._boilerstatus = None + self._away_temperature: float | None = None + self._hg_temperature: float | None = None + self._boilerstatus: bool | None = None self._setpoint_duration = None self._selected_schedule = None @@ -240,9 +254,10 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): registry = await async_get_registry(self.hass) device = registry.async_get_device({(DOMAIN, self._id)}, set()) + assert device self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._home_id] = device.id - async def handle_event(self, event): + async def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -307,22 +322,29 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return @property - def supported_features(self): + def _data(self) -> pyatmo.AsyncHomeData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncHomeData, self.data_handler.data[self._data_classes[0]["name"]] + ) + + @property + def supported_features(self) -> int: """Return the list of supported features.""" return self._support_flags @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temperature @@ -332,12 +354,12 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return PRECISION_HALVES @property - def hvac_mode(self): + def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode.""" return self._hvac_mode @property - def hvac_modes(self): + def hvac_modes(self) -> list[str]: """Return the list of available hvac operation modes.""" return self._operation_list @@ -418,7 +440,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): """Return a list of available preset modes.""" return SUPPORT_PRESET - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: dict) -> None: """Set new target temperature for 2 hours.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: @@ -429,7 +451,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self.async_write_ha_state() - async def async_turn_off(self): + async def async_turn_off(self) -> None: """Turn the entity off.""" if self._model == NA_VALVE: await self._home_status.async_set_room_thermpoint( @@ -443,7 +465,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) self.async_write_ha_state() - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn the entity on.""" await self._home_status.async_set_room_thermpoint(self._id, STATE_NETATMO_HOME) self.async_write_ha_state() @@ -454,7 +476,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return bool(self._connected) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" self._home_status = self.data_handler.data[self._home_status_class] if self._home_status is None: @@ -487,8 +509,6 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): if "current_temperature" not in roomstatus: return - if self._model is None: - self._model = roomstatus["module_type"] self._current_temperature = roomstatus["current_temperature"] self._target_temperature = roomstatus["target_temperature"] self._preset = NETATMO_MAP_PRESET[roomstatus["setpoint_mode"]] @@ -511,7 +531,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ATTR_SELECTED_SCHEDULE ] = self._selected_schedule - def _build_room_status(self): + def _build_room_status(self) -> dict: """Construct room status.""" try: roomstatus = { @@ -570,7 +590,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return {} - async def _async_service_set_schedule(self, **kwargs): + async def _async_service_set_schedule(self, **kwargs: dict) -> 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(): @@ -592,12 +612,14 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info for the thermostat.""" - return {**super().device_info, "suggested_area": self._room_data["name"]} + 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) -> list[str]: +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 [] diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 909255aa38e..9b7c3376076 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Netatmo.""" +from __future__ import annotations + import logging import uuid @@ -7,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from .const import ( @@ -32,7 +35,9 @@ class NetatmoFlowHandler( @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: """Get the options flow for this handler.""" return NetatmoOptionsFlowHandler(config_entry) @@ -62,7 +67,7 @@ class NetatmoFlowHandler( return {"scope": " ".join(scopes)} - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) @@ -81,17 +86,19 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): self.options = dict(config_entry.options) self.options.setdefault(CONF_WEATHER_AREAS, {}) - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: dict | None = None) -> FlowResult: """Manage the Netatmo options.""" return await self.async_step_public_weather_areas() - async def async_step_public_weather_areas(self, user_input=None): + async def async_step_public_weather_areas( + self, user_input: dict | None = None + ) -> FlowResult: """Manage configuration of Netatmo public weather areas.""" - errors = {} + errors: dict = {} if user_input is not None: new_client = user_input.pop(CONF_NEW_AREA, None) - areas = user_input.pop(CONF_WEATHER_AREAS, None) + areas = user_input.pop(CONF_WEATHER_AREAS, []) user_input[CONF_WEATHER_AREAS] = { area: self.options[CONF_WEATHER_AREAS][area] for area in areas } @@ -110,7 +117,7 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_WEATHER_AREAS, default=weather_areas, - ): cv.multi_select(weather_areas), + ): cv.multi_select({wa: None for wa in weather_areas}), vol.Optional(CONF_NEW_AREA): str, } ) @@ -120,7 +127,7 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): errors=errors, ) - async def async_step_public_weather(self, user_input=None): + async def async_step_public_weather(self, user_input: dict) -> FlowResult: """Manage configuration of Netatmo public weather sensors.""" if user_input is not None and CONF_NEW_AREA not in user_input: self.options[CONF_WEATHER_AREAS][ @@ -181,14 +188,14 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="public_weather", data_schema=data_schema) - def _create_options_entry(self): + def _create_options_entry(self) -> FlowResult: """Update config entry options.""" return self.async_create_entry( title="Netatmo Public Weather", data=self.options ) -def fix_coordinates(user_input): +def fix_coordinates(user_input: dict) -> dict: """Fix coordinates if they don't comply with the Netatmo API.""" # Ensure coordinates have acceptable length for the Netatmo API for coordinate in (CONF_LAT_NE, CONF_LAT_SW, CONF_LON_NE, CONF_LON_SW): diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 8b2fb8701da..f6806ace324 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -6,6 +6,7 @@ from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN API = "api" +UNKNOWN = "unknown" DOMAIN = "netatmo" MANUFACTURER = "Netatmo" @@ -76,7 +77,7 @@ DATA_SCHEDULES = "netatmo_schedules" NETATMO_WEBHOOK_URL = None NETATMO_EVENT = "netatmo_event" -DEFAULT_PERSON = "Unknown" +DEFAULT_PERSON = UNKNOWN DEFAULT_DISCOVERY = True DEFAULT_WEBHOOKS = False diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 5e007e8634d..128a3174b9d 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -8,6 +8,7 @@ from datetime import timedelta from itertools import islice import logging from time import time +from typing import Any import pyatmo @@ -75,11 +76,11 @@ class NetatmoDataHandler: self._auth = hass.data[DOMAIN][entry.entry_id][AUTH] self.listeners: list[CALLBACK_TYPE] = [] self.data_classes: dict = {} - self.data = {} - self._queue = deque() + self.data: dict = {} + self._queue: deque = deque() self._webhook: bool = False - async def async_setup(self): + async def async_setup(self) -> None: """Set up the Netatmo data handler.""" async_track_time_interval( @@ -94,7 +95,7 @@ class NetatmoDataHandler: ) ) - async def async_update(self, event_time): + async def async_update(self, event_time: timedelta) -> None: """ Update device. @@ -115,17 +116,17 @@ class NetatmoDataHandler: self._queue.rotate(BATCH_SIZE) @callback - def async_force_update(self, data_class_entry): + def async_force_update(self, data_class_entry: str) -> None: """Prioritize data retrieval for given data class entry.""" self.data_classes[data_class_entry].next_scan = time() self._queue.rotate(-(self._queue.index(self.data_classes[data_class_entry]))) - async def async_cleanup(self): + async def async_cleanup(self) -> None: """Clean up the Netatmo data handler.""" for listener in self.listeners: listener() - async def handle_event(self, event): + async def handle_event(self, event: dict) -> None: """Handle webhook events.""" if event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_ACTIVATION: _LOGGER.info("%s webhook successfully registered", MANUFACTURER) @@ -139,7 +140,7 @@ class NetatmoDataHandler: _LOGGER.debug("%s camera reconnected", MANUFACTURER) self.async_force_update(CAMERA_DATA_CLASS_NAME) - async def async_fetch_data(self, data_class_entry): + async def async_fetch_data(self, data_class_entry: str) -> None: """Fetch data and notify.""" if self.data[data_class_entry] is None: return @@ -163,8 +164,12 @@ class NetatmoDataHandler: update_callback() async def register_data_class( - self, data_class_name, data_class_entry, update_callback, **kwargs - ): + self, + data_class_name: str, + data_class_entry: str, + update_callback: CALLBACK_TYPE, + **kwargs: Any, + ) -> None: """Register data class.""" if data_class_entry in self.data_classes: if update_callback not in self.data_classes[data_class_entry].subscriptions: @@ -189,7 +194,9 @@ class NetatmoDataHandler: self._queue.append(self.data_classes[data_class_entry]) _LOGGER.debug("Data class %s added", data_class_entry) - async def unregister_data_class(self, data_class_entry, update_callback): + async def unregister_data_class( + self, data_class_entry: str, update_callback: CALLBACK_TYPE | None + ) -> None: """Unregister data class.""" self.data_classes[data_class_entry].subscriptions.remove(update_callback) diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index b0d4e18b7c9..65bc79ee712 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -63,7 +63,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_validate_trigger_config(hass, config): +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: """Validate config.""" config = TRIGGER_SCHEMA(config) @@ -129,10 +131,10 @@ async def async_attach_trigger( device = device_registry.async_get(config[CONF_DEVICE_ID]) if not device: - return + return lambda: None if device.model not in DEVICES: - return + return lambda: None event_config = { event_trigger.CONF_PLATFORM: "event", @@ -142,10 +144,14 @@ async def async_attach_trigger( ATTR_DEVICE_ID: config[ATTR_DEVICE_ID], }, } + # if config[CONF_TYPE] in SUBTYPES: + # event_config[event_trigger.CONF_EVENT_DATA]["data"] = { + # "mode": config[CONF_SUBTYPE] + # } if config[CONF_TYPE] in SUBTYPES: - event_config[event_trigger.CONF_EVENT_DATA]["data"] = { - "mode": config[CONF_SUBTYPE] - } + event_config.update( + {event_trigger.CONF_EVENT_DATA: {"data": {"mode": config[CONF_SUBTYPE]}}} + ) event_config = event_trigger.TRIGGER_SCHEMA(event_config) return await event_trigger.async_attach_trigger( diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py index d9ef4d1e455..7e8f32817dd 100644 --- a/homeassistant/components/netatmo/helper.py +++ b/homeassistant/components/netatmo/helper.py @@ -1,6 +1,6 @@ """Helper for Netatmo integration.""" from dataclasses import dataclass -from uuid import uuid4 +from uuid import UUID, uuid4 @dataclass @@ -14,4 +14,4 @@ class NetatmoArea: lon_sw: float mode: str show_on_map: bool - uuid: str = uuid4() + uuid: UUID = uuid4() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 07488ad03b5..717aace1aa2 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -1,10 +1,17 @@ """Support for the Netatmo camera lights.""" +from __future__ import annotations + import logging +from typing import cast + +import pyatmo from homeassistant.components.light import LightEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DATA_HANDLER, @@ -12,6 +19,7 @@ from .const import ( EVENT_TYPE_LIGHT_MODE, MANUFACTURER, SIGNAL_NAME, + UNKNOWN, WEBHOOK_LIGHT_MODE, WEBHOOK_PUSH_TYPE, ) @@ -21,7 +29,9 @@ from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) -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 Netatmo camera light platform.""" if "access_camera" not in entry.data["token"]["scope"]: _LOGGER.info( @@ -79,7 +89,7 @@ class NetatmoLight(NetatmoBase, LightEntity): self._id = camera_id self._home_id = home_id self._model = camera_type - self._device_name = self._data.get_camera(camera_id).get("name") + self._device_name: str = self._data.get_camera(camera_id).get("name", UNKNOWN) self._attr_name = f"{MANUFACTURER} {self._device_name}" self._is_on = False self._attr_unique_id = f"{self._id}-light" @@ -97,7 +107,7 @@ class NetatmoLight(NetatmoBase, LightEntity): ) @callback - def handle_event(self, event): + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -114,17 +124,25 @@ class NetatmoLight(NetatmoBase, LightEntity): self.async_write_ha_state() return + @property + def _data(self) -> pyatmo.AsyncCameraData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncCameraData, + self.data_handler.data[self._data_classes[0]["name"]], + ) + @property def available(self) -> bool: """If the webhook is not established, mark as unavailable.""" return bool(self.data_handler.webhook) @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" return self._is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: dict) -> None: """Turn camera floodlight on.""" _LOGGER.debug("Turn camera '%s' on", self.name) await self._data.async_set_state( @@ -133,7 +151,7 @@ class NetatmoLight(NetatmoBase, LightEntity): floodlight="on", ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: dict) -> None: """Turn camera floodlight into auto mode.""" _LOGGER.debug("Turn camera '%s' to auto mode", self.name) await self._data.async_set_state( @@ -143,6 +161,6 @@ class NetatmoLight(NetatmoBase, LightEntity): ) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" self._is_on = bool(self._data.get_light_state(self._id) == "on") diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index de7fbc36038..84ef65f3001 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==5.2.0" + "pyatmo==5.2.1" ], "after_dependencies": [ "cloud", diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 99f52d95ad4..d80225c0368 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -11,7 +11,6 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_VIDEO, ) from homeassistant.components.media_player.errors import BrowseError -from homeassistant.components.media_source.const import MEDIA_MIME_TYPES from homeassistant.components.media_source.error import MediaSourceError, Unresolvable from homeassistant.components.media_source.models import ( BrowseMediaSource, @@ -31,7 +30,7 @@ class IncompatibleMediaSource(MediaSourceError): """Incompatible media source attributes.""" -async def async_get_media_source(hass: HomeAssistant): +async def async_get_media_source(hass: HomeAssistant) -> NetatmoSource: """Set up Netatmo media source.""" return NetatmoSource(hass) @@ -54,7 +53,9 @@ class NetatmoSource(MediaSource): return PlayMedia(url, MIME_TYPE) async def async_browse_media( - self, item: MediaSourceItem, media_types: tuple[str] = MEDIA_MIME_TYPES + self, + item: MediaSourceItem, + media_types: tuple[str] = ("video",), ) -> BrowseMediaSource: """Return media.""" try: @@ -65,7 +66,7 @@ class NetatmoSource(MediaSource): return self._browse_media(source, camera_id, event_id) def _browse_media( - self, source: str, camera_id: str, event_id: int + self, source: str, camera_id: str, event_id: int | None ) -> BrowseMediaSource: """Browse media.""" if camera_id and camera_id not in self.events: @@ -77,7 +78,7 @@ class NetatmoSource(MediaSource): return self._build_item_response(source, camera_id, event_id) def _build_item_response( - self, source: str, camera_id: str, event_id: int = None + self, source: str, camera_id: str, event_id: int | None = None ) -> BrowseMediaSource: if event_id and event_id in self.events[camera_id]: created = dt.datetime.fromtimestamp(event_id) @@ -148,7 +149,7 @@ class NetatmoSource(MediaSource): return media -def remove_html_tags(text): +def remove_html_tags(text: str) -> str: """Remove html tags from string.""" clean = re.compile("<.*?>") return re.sub(clean, "", text) diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index eb65bc4da0f..51fc14f6f8e 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import CALLBACK_TYPE, callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( DATA_DEVICE_IDS, @@ -25,12 +25,14 @@ class NetatmoBase(Entity): self._data_classes: list[dict] = [] self._listeners: list[CALLBACK_TYPE] = [] - self._device_name = None - self._id = None - self._model = None + self._device_name: str = "" + self._id: str = "" + self._model: str = "" self._attr_name = None self._attr_unique_id = None - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes: dict = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION + } async def async_added_to_hass(self) -> None: """Entity created.""" @@ -71,7 +73,7 @@ class NetatmoBase(Entity): self.async_update_callback() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -84,17 +86,12 @@ class NetatmoBase(Entity): ) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" raise NotImplementedError @property - def _data(self): - """Return data for this entity.""" - return self.data_handler.data[self._data_classes[0]["name"]] - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info for the sensor.""" return { "identifiers": {(DOMAIN, self._id)}, diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 46bb06149cd..6204e229108 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -2,9 +2,12 @@ from __future__ import annotations import logging -from typing import NamedTuple +from typing import NamedTuple, cast + +import pyatmo from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -24,19 +27,21 @@ from homeassistant.const import ( SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import async_entries_for_config_entry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_WEATHER_AREAS, DATA_HANDLER, DOMAIN, MANUFACTURER, SIGNAL_NAME from .data_handler import ( HOMECOACH_DATA_CLASS_NAME, PUBLICDATA_DATA_CLASS_NAME, WEATHERSTATION_DATA_CLASS_NAME, + NetatmoDataHandler, ) from .helper import NetatmoArea from .netatmo_entity_base import NetatmoBase @@ -267,12 +272,14 @@ BATTERY_VALUES = { PUBLIC = "public" -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 Netatmo weather and homecoach platform.""" data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] platform_not_ready = True - async def find_entities(data_class_name): + async def find_entities(data_class_name: str) -> list: """Find all entities.""" all_module_infos = {} data = data_handler.data @@ -330,7 +337,7 @@ async def async_setup_entry(hass, entry, async_add_entities): device_registry = await hass.helpers.device_registry.async_get_registry() - async def add_public_entities(update=True): + async def add_public_entities(update: bool = True) -> None: """Retrieve Netatmo public weather entities.""" entities = { device.name: device.id @@ -396,7 +403,13 @@ async def async_setup_entry(hass, entry, async_add_entities): class NetatmoSensor(NetatmoBase, SensorEntity): """Implementation of a Netatmo sensor.""" - def __init__(self, data_handler, data_class_name, module_info, sensor_type): + def __init__( + self, + data_handler: NetatmoDataHandler, + data_class_name: str, + module_info: dict, + sensor_type: str, + ) -> None: """Initialize the sensor.""" super().__init__(data_handler) @@ -434,20 +447,21 @@ class NetatmoSensor(NetatmoBase, SensorEntity): self._attr_entity_registry_enabled_default = metadata.enable_default @property - def available(self): + def _data(self) -> pyatmo.AsyncWeatherStationData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncWeatherStationData, + self.data_handler.data[self._data_classes[0]["name"]], + ) + + @property + def available(self) -> bool: """Return entity availability.""" return self.state is not None @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" - if self._data is None: - if self.state is None: - return - _LOGGER.warning("No data from update") - self._attr_state = None - return - data = self._data.get_last_data(station_id=self._station_id, exclude=3600).get( self._id ) @@ -531,7 +545,7 @@ def process_battery(data: int, model: str) -> str: return "Very Low" -def process_health(health): +def process_health(health: int) -> str: """Process health index and return string for display.""" if health == 0: return "Healthy" @@ -541,11 +555,10 @@ def process_health(health): return "Fair" if health == 3: return "Poor" - if health == 4: - return "Unhealthy" + return "Unhealthy" -def process_rf(strength): +def process_rf(strength: int) -> str: """Process wifi signal strength and return string for display.""" if strength >= 90: return "Low" @@ -556,7 +569,7 @@ def process_rf(strength): return "Full" -def process_wifi(strength): +def process_wifi(strength: int) -> str: """Process wifi signal strength and return string for display.""" if strength >= 86: return "Low" @@ -570,7 +583,9 @@ def process_wifi(strength): class NetatmoPublicSensor(NetatmoBase, SensorEntity): """Represent a single sensor in a Netatmo.""" - def __init__(self, data_handler, area, sensor_type): + def __init__( + self, data_handler: NetatmoDataHandler, area: NetatmoArea, sensor_type: str + ) -> None: """Initialize the sensor.""" super().__init__(data_handler) @@ -611,13 +626,15 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): ) @property - def _data(self): - return self.data_handler.data[self._signal_name] + def _data(self) -> pyatmo.AsyncPublicData: + """Return data for this entity.""" + return cast(pyatmo.AsyncPublicData, self.data_handler.data[self._signal_name]) async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() + assert self.device_info and "name" in self.device_info self.data_handler.listeners.append( async_dispatcher_connect( self.hass, @@ -626,7 +643,7 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): ) ) - async def async_config_update_callback(self, area): + async def async_config_update_callback(self, area: NetatmoArea) -> None: """Update the entity's config.""" if self.area == area: return @@ -661,7 +678,7 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): ) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" data = None diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 54db95e9aa0..4f39d5fe5f5 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,7 +1,10 @@ """The Netatmo integration.""" import logging +from aiohttp.web import Request + from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -25,7 +28,9 @@ SUBEVENT_TYPE_MAP = { } -async def async_handle_webhook(hass, webhook_id, request): +async def async_handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> None: """Handle webhook callback.""" try: data = await request.json() @@ -47,12 +52,12 @@ async def async_handle_webhook(hass, webhook_id, request): async_evaluate_event(hass, data) -def async_evaluate_event(hass, event_data): +def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: """Evaluate events from webhook.""" - event_type = event_data.get(ATTR_EVENT_TYPE) + event_type = event_data.get(ATTR_EVENT_TYPE, "None") if event_type == "person": - for person in event_data.get(ATTR_PERSONS): + for person in event_data.get(ATTR_PERSONS, {}): person_event_data = dict(event_data) person_event_data[ATTR_ID] = person.get(ATTR_ID) person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS].get( @@ -67,7 +72,7 @@ def async_evaluate_event(hass, event_data): async_send_event(hass, event_type, event_data) -def async_send_event(hass, event_type, data): +def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: """Send events.""" _LOGGER.debug("%s: %s", event_type, data) async_dispatcher_send( diff --git a/mypy.ini b/mypy.ini index 67f631f05bf..32fff8d5105 100644 --- a/mypy.ini +++ b/mypy.ini @@ -671,6 +671,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.netatmo.*] +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.network.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1388,9 +1399,6 @@ ignore_errors = true [mypy-homeassistant.components.nest.legacy.*] ignore_errors = true -[mypy-homeassistant.components.netatmo.*] -ignore_errors = true - [mypy-homeassistant.components.netio.*] ignore_errors = true diff --git a/requirements_all.txt b/requirements_all.txt index 03bad6a7778..50ca781cfc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1316,7 +1316,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.0 +pyatmo==5.2.1 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8fbf70e72c..88b251137ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -747,7 +747,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.0 +pyatmo==5.2.1 # homeassistant.components.apple_tv pyatv==0.8.1 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 7c97134397b..8dff2c8f89f 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -110,7 +110,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.neato.*", "homeassistant.components.ness_alarm.*", "homeassistant.components.nest.legacy.*", - "homeassistant.components.netatmo.*", "homeassistant.components.netio.*", "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 838b2e2d290..8be010cc802 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -16,10 +16,10 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - select_entity_livingroom = "select.netatmo_myhome" + select_entity = "select.netatmo_myhome" - assert hass.states.get(select_entity_livingroom).state == "Default" - assert hass.states.get(select_entity_livingroom).attributes[ATTR_OPTIONS] == [ + assert hass.states.get(select_entity).state == "Default" + assert hass.states.get(select_entity).attributes[ATTR_OPTIONS] == [ "Default", "Winter", ] @@ -33,7 +33,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a } await simulate_webhook(hass, webhook_id, response) - assert hass.states.get(select_entity_livingroom).state == "Winter" + assert hass.states.get(select_entity).state == "Winter" # Test setting a different schedule with patch( @@ -43,7 +43,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a SELECT_DOMAIN, SERVICE_SELECT_OPTION, { - ATTR_ENTITY_ID: select_entity_livingroom, + ATTR_ENTITY_ID: select_entity, ATTR_OPTION: "Default", }, blocking=True, @@ -62,4 +62,4 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a } await simulate_webhook(hass, webhook_id, response) - assert hass.states.get(select_entity_livingroom).state == "Default" + assert hass.states.get(select_entity).state == "Default" From 86752516eeb43bd1eecb7b35e90c58416afcb05d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Jul 2021 14:48:21 -0700 Subject: [PATCH 075/112] Add WS API to access solar forecast data (#53264) Co-authored-by: Franck Nijhof --- .../components/forecast_solar/__init__.py | 25 +++++++++++++++++-- tests/components/forecast_solar/test_init.py | 24 ++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index b00e5f1c4ce..b20a0befb96 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -5,10 +5,12 @@ 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 +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -55,7 +57,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) + 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.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -77,3 +81,20 @@ 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] = { + timestamp.isoformat(): val + for timestamp, val in coordinator.data.watts.items() + } + + connection.send_result(msg["id"], forecasts) diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 719041aaf58..7544f7d352b 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -1,4 +1,5 @@ """Tests for the Forecast.Solar integration.""" +from datetime import datetime, timezone from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError @@ -14,14 +15,37 @@ 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.watts = { + 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 + # 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: { + "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 9f14b2cef50c1ff3af922922e1b94b84b812d167 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 22 Jul 2021 00:04:14 +0200 Subject: [PATCH 076/112] Test KNX switch (#53289) --- tests/components/knx/README.md | 71 +++++++++++++ tests/components/knx/conftest.py | 26 ++--- tests/components/knx/test_switch.py | 150 ++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 tests/components/knx/README.md create mode 100644 tests/components/knx/test_switch.py diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md new file mode 100644 index 00000000000..4b5886200c4 --- /dev/null +++ b/tests/components/knx/README.md @@ -0,0 +1,71 @@ +# Testing the KNX integration + +A KNXTestKit instance can be requested from a fixture. It provides convenience methods +to test outgoing KNX telegrams and inject incoming telegrams. +To test something add a test function requesting the `hass` and `knx` fixture and +set up the KNX integration by passing a KNX config dict to `knx.setup_integration`. + +```python +async def test_something(hass, knx): + await knx.setup_integration({ + "switch": { + "name": "test_switch", + "address": "1/2/3", + } + } + ) +``` + +## Asserting outgoing telegrams + +All outgoing telegrams are pushed to an assertion queue. Assert them in order they were sent. + +- `knx.assert_no_telegram` + Asserts that no telegram was sent (assertion queue is empty). +- `knx.assert_telegram_count(count: int)` + Asserts that `count` telegrams were sent. +- `knx.assert_read(group_address: str)` + Asserts that a GroupValueRead telegram was sent to `group_address`. + The telegram will be removed from the assertion queue. +- `knx.assert_response(group_address: str, payload: int | tuple[int, ...])` + Asserts that a GroupValueResponse telegram with `payload` was sent to `group_address`. + The telegram will be removed from the assertion queue. +- `knx.assert_write(group_address: str, payload: int | tuple[int, ...])` + Asserts that a GroupValueWrite telegram with `payload` was sent to `group_address`. + The telegram will be removed from the assertion queue. + +Change some states or call some services and assert outgoing telegrams. + +```python + # turn on switch + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test_switch"}, blocking=True + ) + # assert ON telegram + await knx.assert_write("1/2/3", True) +``` + +## Injecting incoming telegrams + +- `knx.receive_read(group_address: str)` + Inject and process a GroupValueRead telegram addressed to `group_address`. +- `knx.receive_response(group_address: str, payload: int | tuple[int, ...])` + Inject and process a GroupValueResponse telegram addressed to `group_address` containing `payload`. +- `knx.receive_write(group_address: str, payload: int | tuple[int, ...])` + Inject and process a GroupValueWrite telegram addressed to `group_address` containing `payload`. + +Receive some telegrams and assert state. + +```python + # receive OFF telegram + await knx.receive_write("1/2/3", False) + # assert OFF state + state = hass.states.get("switch.test_switch") + assert state.state is STATE_OFF +``` + +## Notes + +- For `payload` in `assert_*` and `receive_*` use `int` for DPT 1, 2 and 3 payload values (DPTBinary) and `tuple` for other DPTs (DPTArray). +- `await self.hass.async_block_till_done()` is called before `KNXTestKit.assert_*` and after `KNXTestKit.receive_*` so you don't have to explicitly call it. +- Make sure to assert every outgoing telegram that was created in a test. `assert_no_telegram` is automatically called on teardown. diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 548e620813a..26f8f1eabac 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -41,6 +41,8 @@ class KNXTestKit: def fish_xknx(*args, **kwargs): """Get the XKNX object from the constructor call.""" self.xknx = args[0] + # disable rate limiter for tests (before StateUpdater starts) + self.xknx.rate_limit = 0 return DEFAULT with patch( @@ -50,8 +52,6 @@ class KNXTestKit: ): await async_setup_component(self.hass, KNX_DOMAIN, {KNX_DOMAIN: config}) await self.hass.async_block_till_done() - # disable rate limiter for tests - self.xknx.rate_limit = 0 ######################## # Telegram counter tests @@ -101,14 +101,14 @@ class KNXTestKit: f" {group_address} - {payload}" ) - assert ( - str(telegram.destination_address) == group_address - ), f"Group address mismatch in {telegram} - Expected: {group_address}" - assert isinstance( telegram.payload, apci_type ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" + assert ( + str(telegram.destination_address) == group_address + ), f"Group address mismatch in {telegram} - Expected: {group_address}" + if payload is not None: assert ( telegram.payload.value.value == payload # type: ignore @@ -134,6 +134,13 @@ class KNXTestKit: # Incoming telegrams #################### + @staticmethod + def _payload_value(payload: int | tuple[int, ...]) -> DPTArray | DPTBinary: + """Prepare payload value for GroupValueWrite or GroupValueResponse.""" + if isinstance(payload, int): + return DPTBinary(payload) + return DPTArray(payload) + async def _receive_telegram(self, group_address: str, payload: APCI) -> None: """Inject incoming KNX telegram.""" self.xknx.telegrams.put_nowait( @@ -146,13 +153,6 @@ class KNXTestKit: ) await self.hass.async_block_till_done() - @staticmethod - def _payload_value(payload: int | tuple[int, ...]) -> DPTArray | DPTBinary: - """Prepare payload value for GroupValueWrite or GroupValueResponse.""" - if isinstance(payload, int): - return DPTBinary(payload) - return DPTArray(payload) - async def receive_read( self, group_address: str, diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py new file mode 100644 index 00000000000..407d6d83267 --- /dev/null +++ b/tests/components/knx/test_switch.py @@ -0,0 +1,150 @@ +"""Test KNX switch.""" +from unittest.mock import patch + +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) +from homeassistant.components.knx.schema import SwitchSchema +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import State + + +async def test_switch_simple(hass, knx): + """Test simple KNX switch.""" + await knx.setup_integration( + { + SwitchSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on switch + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/3", True) + + # turn off switch + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/3", False) + + # receive ON telegram + await knx.receive_write("1/2/3", True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # receive OFF telegram + await knx.receive_write("1/2/3", False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + # switch does not respond to read by default + await knx.receive_read("1/2/3") + await knx.assert_telegram_count(0) + + +async def test_switch_state(hass, knx): + """Test KNX switch with state_address.""" + _ADDRESS = "1/1/1" + _STATE_ADDRESS = "2/2/2" + + await knx.setup_integration( + { + SwitchSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: _ADDRESS, + CONF_STATE_ADDRESS: _STATE_ADDRESS, + }, + } + ) + assert len(hass.states.async_all()) == 1 + + # StateUpdater initialize state + await knx.assert_read(_STATE_ADDRESS) + await knx.receive_response(_STATE_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # receive OFF telegram at `address` + await knx.receive_write(_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + # receive ON telegram at `address` + await knx.receive_write(_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # receive OFF telegram at `state_address` + await knx.receive_write(_STATE_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + # receive ON telegram at `state_address` + await knx.receive_write(_STATE_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # turn off switch + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write(_ADDRESS, False) + + # turn on switch + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write(_ADDRESS, True) + + # switch does not respond to read by default + await knx.receive_read(_ADDRESS) + await knx.assert_telegram_count(0) + + +async def test_switch_restore_and_respond(hass, knx): + """Test restoring KNX switch state and respond to read.""" + _ADDRESS = "1/1/1" + fake_state = State("switch.test", "on") + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + await knx.setup_integration( + { + SwitchSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: _ADDRESS, + CONF_RESPOND_TO_READ: True, + }, + } + ) + + # restored state - doesn't send telegram + state = hass.states.get("switch.test") + assert state.state == STATE_ON + await knx.assert_telegram_count(0) + + # respond to restored state + await knx.receive_read(_ADDRESS) + await knx.assert_response(_ADDRESS, True) + + # turn off switch + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write(_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state == STATE_OFF + + # respond to new state + await knx.receive_read(_ADDRESS) + await knx.assert_response(_ADDRESS, False) From edf42bab25ae2e3681f3d0e774b7eebdaaa87ce3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Jul 2021 15:04:30 -0700 Subject: [PATCH 077/112] Migrate forecast solar to v2 (#53259) --- .../components/forecast_solar/const.py | 18 +++++++++ .../components/forecast_solar/manifest.json | 2 +- .../components/forecast_solar/models.py | 4 ++ .../components/forecast_solar/sensor.py | 8 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/forecast_solar/conftest.py | 39 ++++++++++++------- .../components/forecast_solar/test_sensor.py | 16 ++++---- 8 files changed, 64 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 12aa1ee5362..7372ac5954d 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -1,6 +1,7 @@ """Constants for the Forecast.Solar integration.""" from __future__ import annotations +from datetime import timedelta from typing import Final from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT @@ -27,12 +28,14 @@ SENSORS: list[ForecastSolarSensor] = [ ForecastSolarSensor( key="energy_production_today", 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, ), ForecastSolarSensor( 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, ), @@ -50,11 +53,16 @@ SENSORS: list[ForecastSolarSensor] = [ key="power_production_now", name="Estimated Power Production - Now", device_class=DEVICE_CLASS_POWER, + state=lambda estimate: estimate.power_production_now / 1000, state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=POWER_WATT, ), ForecastSolarSensor( 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, @@ -62,6 +70,10 @@ SENSORS: list[ForecastSolarSensor] = [ ), ForecastSolarSensor( 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, @@ -69,6 +81,10 @@ SENSORS: list[ForecastSolarSensor] = [ ), ForecastSolarSensor( 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, @@ -77,11 +93,13 @@ SENSORS: list[ForecastSolarSensor] = [ ForecastSolarSensor( 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, ), ForecastSolarSensor( 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, diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index c17e8bd51f8..2b57eed84ac 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==1.3.1"], + "requirements": ["forecast_solar==2.0.0"], "codeowners": ["@klaasnicolaas", "@frenck"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/forecast_solar/models.py b/homeassistant/components/forecast_solar/models.py index d01f17fc975..a10f52ebcd3 100644 --- a/homeassistant/components/forecast_solar/models.py +++ b/homeassistant/components/forecast_solar/models.py @@ -2,6 +2,9 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any, Callable + +from forecast_solar.models import Estimate @dataclass @@ -13,5 +16,6 @@ class ForecastSolarSensor: device_class: str | None = None entity_registry_enabled_default: bool = True + state: Callable[[Estimate], Any] | None = None state_class: str | None = None unit_of_measurement: str | None = None diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index b32f1f341be..e73b2105b8e 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -66,7 +66,13 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): @property def state(self) -> StateType: """Return the state of the sensor.""" - state: StateType | datetime = getattr(self.coordinator.data, self._sensor.key) + if self._sensor.state is None: + state: StateType | datetime = getattr( + self.coordinator.data, self._sensor.key + ) + else: + state = self._sensor.state(self.coordinator.data) + if isinstance(state, datetime): return state.isoformat() return state diff --git a/requirements_all.txt b/requirements_all.txt index 50ca781cfc8..927a685ded4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -630,7 +630,7 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==1.3.1 +forecast_solar==2.0.0 # homeassistant.components.fortios fortiosapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88b251137ed..101e0bbe4b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -348,7 +348,7 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==1.3.1 +forecast_solar==2.0.0 # homeassistant.components.freebox freebox-api==0.0.10 diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index c2b5fc08181..88f3bf9d4a4 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -1,9 +1,10 @@ """Fixtures for Forecast.Solar integration tests.""" -import datetime +from datetime import datetime, timedelta from typing import Generator from unittest.mock import MagicMock, patch +from forecast_solar import models import pytest from homeassistant.components.forecast_solar.const import ( @@ -16,6 +17,7 @@ from homeassistant.components.forecast_solar.const import ( from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -54,24 +56,31 @@ def mock_forecast_solar() -> Generator[None, MagicMock, None]: "homeassistant.components.forecast_solar.ForecastSolar", autospec=True ) as forecast_solar_mock: forecast_solar = forecast_solar_mock.return_value + now = datetime(2021, 6, 27, 6, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE) - estimate = MagicMock() + estimate = MagicMock(spec_set=models.Estimate) + estimate.now.return_value = now estimate.timezone = "Europe/Amsterdam" - estimate.energy_production_today = 100 - estimate.energy_production_tomorrow = 200 - estimate.power_production_now = 300 - estimate.power_highest_peak_time_today = datetime.datetime( - 2021, 6, 27, 13, 0, tzinfo=datetime.timezone.utc + estimate.energy_production_today = 100000 + estimate.energy_production_tomorrow = 200000 + estimate.power_production_now = 300000 + estimate.power_highest_peak_time_today = datetime( + 2021, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE ) - estimate.power_highest_peak_time_tomorrow = datetime.datetime( - 2021, 6, 27, 14, 0, tzinfo=datetime.timezone.utc + estimate.power_highest_peak_time_tomorrow = datetime( + 2021, 6, 27, 14, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE ) - estimate.power_production_next_hour = 400 - estimate.power_production_next_6hours = 500 - estimate.power_production_next_12hours = 600 - estimate.power_production_next_24hours = 700 - estimate.energy_current_hour = 800 - estimate.energy_next_hour = 900 + estimate.energy_current_hour = 800000 + + estimate.power_production_at_time.side_effect = { + now + timedelta(hours=1): 400000, + now + timedelta(hours=12): 600000, + now + timedelta(hours=24): 700000, + }.get + + estimate.sum_energy_production.side_effect = { + 1: 900000, + }.get forecast_solar.estimate.return_value = estimate yield forecast_solar diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index 31c367678c1..8b8c1cc933e 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -40,7 +40,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_production_today" - assert state.state == "100" + assert state.state == "100.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - Today" @@ -55,7 +55,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_production_tomorrow" - assert state.state == "200" + assert state.state == "200.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - Tomorrow" @@ -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" + assert state.state == "300.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Power Production - Now" ) @@ -110,7 +110,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_current_hour" - assert state.state == "800" + assert state.state == "800.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - This Hour" @@ -125,7 +125,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_next_hour" - assert state.state == "900" + assert state.state == "900.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - Next Hour" @@ -175,17 +175,17 @@ async def test_disabled_by_default( ( "power_production_next_12hours", "Estimated Power Production - Next 12 Hours", - "600", + "600.0", ), ( "power_production_next_24hours", "Estimated Power Production - Next 24 Hours", - "700", + "700.0", ), ( "power_production_next_hour", "Estimated Power Production - Next Hour", - "400", + "400.0", ), ], ) From ecf0d4398dae241ba9b0c31f76e728c1e4d494a0 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 22 Jul 2021 00:10:31 +0000 Subject: [PATCH 078/112] [ci skip] Translation update --- .../components/abode/translations/de.json | 2 +- .../components/adax/translations/ca.json | 20 +++++++++++ .../components/adax/translations/de.json | 20 +++++++++++ .../components/adax/translations/en.json | 9 +++-- .../components/adax/translations/et.json | 20 +++++++++++ .../components/adax/translations/ru.json | 20 +++++++++++ .../components/adguard/translations/de.json | 2 +- .../advantage_air/translations/de.json | 2 +- .../components/airvisual/translations/de.json | 4 +-- .../airvisual/translations/sensor.de.json | 20 +++++++++++ .../ambiclimate/translations/de.json | 2 +- .../ambient_station/translations/de.json | 2 +- .../components/atag/translations/de.json | 2 +- .../binary_sensor/translations/de.json | 14 ++++---- .../components/blebox/translations/de.json | 2 +- .../components/bond/translations/de.json | 2 +- .../components/broadlink/translations/de.json | 4 +-- .../components/brother/translations/de.json | 2 +- .../components/bsblan/translations/de.json | 2 +- .../components/cast/translations/de.json | 2 +- .../cert_expiry/translations/de.json | 2 +- .../cloudflare/translations/de.json | 2 +- .../components/co2signal/translations/de.json | 34 +++++++++++++++++++ .../components/coinbase/translations/de.json | 1 + .../components/control4/translations/de.json | 2 +- .../coronavirus/translations/de.json | 2 +- .../components/daikin/translations/de.json | 2 +- .../components/dexcom/translations/de.json | 2 +- .../components/directv/translations/de.json | 2 +- .../components/doorbird/translations/de.json | 2 +- .../components/dunehd/translations/de.json | 2 +- .../components/ecobee/translations/de.json | 4 +-- .../components/elgato/translations/de.json | 2 +- .../components/enocean/translations/de.json | 2 +- .../components/esphome/translations/de.json | 2 +- .../fireservicerota/translations/de.json | 10 +++--- .../flick_electric/translations/de.json | 2 +- .../components/flipr/translations/ca.json | 30 ++++++++++++++++ .../components/flipr/translations/en.json | 26 +++++++------- .../components/flipr/translations/et.json | 30 ++++++++++++++++ .../forked_daapd/translations/de.json | 2 +- .../components/fritzbox/translations/de.json | 2 +- .../garmin_connect/translations/de.json | 2 +- .../geonetnz_quakes/translations/de.json | 2 +- .../components/gios/translations/de.json | 2 +- .../components/glances/translations/de.json | 4 +-- .../components/goalzero/translations/de.json | 2 +- .../growatt_server/translations/ca.json | 1 + .../growatt_server/translations/de.json | 1 + .../growatt_server/translations/en.json | 1 + .../growatt_server/translations/et.json | 1 + .../growatt_server/translations/ru.json | 1 + .../components/hangouts/translations/de.json | 4 +-- .../hisense_aehw4a1/translations/de.json | 2 +- .../homematicip_cloud/translations/de.json | 2 +- .../components/honeywell/translations/de.json | 17 ++++++++++ .../components/hue/translations/de.json | 2 +- .../components/icloud/translations/de.json | 2 +- .../components/iqvia/translations/de.json | 2 +- .../components/izone/translations/de.json | 2 +- .../components/kodi/translations/de.json | 2 +- .../components/life360/translations/de.json | 2 +- .../components/lifx/translations/de.json | 2 +- .../litterrobot/translations/de.json | 2 +- .../components/melcloud/translations/de.json | 2 +- .../components/metoffice/translations/de.json | 4 +-- .../components/mill/translations/de.json | 2 +- .../components/mqtt/translations/de.json | 2 +- .../components/neato/translations/de.json | 2 +- .../components/netatmo/translations/de.json | 4 +-- .../nfandroidtv/translations/ca.json | 21 ++++++++++++ .../nfandroidtv/translations/de.json | 21 ++++++++++++ .../nfandroidtv/translations/en.json | 2 +- .../nfandroidtv/translations/et.json | 21 ++++++++++++ .../nfandroidtv/translations/ru.json | 21 ++++++++++++ .../nmap_tracker/translations/de.json | 2 +- .../components/nzbget/translations/de.json | 4 +-- .../openweathermap/translations/de.json | 2 +- .../components/pi_hole/translations/de.json | 6 ++-- .../components/plex/translations/de.json | 2 +- .../components/plugwise/translations/de.json | 2 +- .../components/point/translations/de.json | 2 +- .../components/ps4/translations/de.json | 4 +-- .../components/rfxtrx/translations/de.json | 2 +- .../components/roku/translations/de.json | 2 +- .../components/samsungtv/translations/de.json | 2 +- .../components/sense/translations/de.json | 2 +- .../simplisafe/translations/de.json | 2 +- .../smartthings/translations/de.json | 2 +- .../components/smarttub/translations/de.json | 2 +- .../components/solaredge/translations/de.json | 4 +-- .../components/sonarr/translations/de.json | 4 +-- .../components/songpal/translations/de.json | 2 +- .../components/sonos/translations/de.json | 2 +- .../speedtestdotnet/translations/de.json | 2 +- .../srp_energy/translations/de.json | 2 +- .../switcher_kis/translations/de.json | 13 +++++++ .../components/syncthru/translations/de.json | 2 +- .../synology_dsm/translations/ca.json | 11 +++++- .../synology_dsm/translations/de.json | 15 ++------ .../synology_dsm/translations/et.json | 11 +++++- .../synology_dsm/translations/ru.json | 11 +++++- .../tellduslive/translations/de.json | 2 +- .../components/tesla/translations/de.json | 2 +- .../components/tibber/translations/de.json | 2 +- .../components/tile/translations/de.json | 4 +-- .../components/timer/translations/de.json | 2 +- .../components/tplink/translations/de.json | 2 +- .../components/tradfri/translations/de.json | 2 +- .../components/unifi/translations/de.json | 2 +- .../components/upnp/translations/de.json | 4 +-- .../components/vesync/translations/de.json | 2 +- .../components/vilfo/translations/de.json | 6 ++-- .../components/wemo/translations/de.json | 2 +- .../components/wiffi/translations/de.json | 2 +- .../components/wled/translations/de.json | 2 +- .../components/xbox/translations/de.json | 2 +- .../xiaomi_aqara/translations/de.json | 2 +- .../xiaomi_miio/translations/de.json | 2 +- .../zoneminder/translations/de.json | 2 +- .../components/zwave/translations/de.json | 6 ++-- .../components/zwave_js/translations/de.json | 8 +++++ 122 files changed, 496 insertions(+), 157 deletions(-) create mode 100644 homeassistant/components/adax/translations/ca.json create mode 100644 homeassistant/components/adax/translations/de.json create mode 100644 homeassistant/components/adax/translations/et.json create mode 100644 homeassistant/components/adax/translations/ru.json create mode 100644 homeassistant/components/airvisual/translations/sensor.de.json create mode 100644 homeassistant/components/co2signal/translations/de.json create mode 100644 homeassistant/components/flipr/translations/ca.json create mode 100644 homeassistant/components/flipr/translations/et.json create mode 100644 homeassistant/components/honeywell/translations/de.json create mode 100644 homeassistant/components/nfandroidtv/translations/ca.json create mode 100644 homeassistant/components/nfandroidtv/translations/de.json create mode 100644 homeassistant/components/nfandroidtv/translations/et.json create mode 100644 homeassistant/components/nfandroidtv/translations/ru.json create mode 100644 homeassistant/components/switcher_kis/translations/de.json diff --git a/homeassistant/components/abode/translations/de.json b/homeassistant/components/abode/translations/de.json index 307f5f45065..695ecba621c 100644 --- a/homeassistant/components/abode/translations/de.json +++ b/homeassistant/components/abode/translations/de.json @@ -26,7 +26,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "title": "Gib deine Abode-Anmeldeinformationen ein" } diff --git a/homeassistant/components/adax/translations/ca.json b/homeassistant/components/adax/translations/ca.json new file mode 100644 index 00000000000..85ba15804ac --- /dev/null +++ b/homeassistant/components/adax/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" + }, + "step": { + "user": { + "data": { + "account_id": "ID del compte", + "host": "Amfitri\u00f3", + "password": "Contrasenya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/de.json b/homeassistant/components/adax/translations/de.json new file mode 100644 index 00000000000..414b373ff34 --- /dev/null +++ b/homeassistant/components/adax/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "account_id": "Konto-ID", + "host": "Host", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/en.json b/homeassistant/components/adax/translations/en.json index a5a204c93f8..d1ef64a52c0 100644 --- a/homeassistant/components/adax/translations/en.json +++ b/homeassistant/components/adax/translations/en.json @@ -5,17 +5,16 @@ }, "error": { "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" + "invalid_auth": "Invalid authentication" }, "step": { "user": { "data": { + "account_id": "Account ID", "host": "Host", - "password": "Password", - "account_id": "Account ID" + "password": "Password" } } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/et.json b/homeassistant/components/adax/translations/et.json new file mode 100644 index 00000000000..c8dd855218c --- /dev/null +++ b/homeassistant/components/adax/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise viga" + }, + "step": { + "user": { + "data": { + "account_id": "Konto ID", + "host": "Host", + "password": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/ru.json b/homeassistant/components/adax/translations/ru.json new file mode 100644 index 00000000000..d0aece78982 --- /dev/null +++ b/homeassistant/components/adax/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." + }, + "step": { + "user": { + "data": { + "account_id": "ID \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438", + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/de.json b/homeassistant/components/adguard/translations/de.json index f73c25d769e..b0a6b480249 100644 --- a/homeassistant/components/adguard/translations/de.json +++ b/homeassistant/components/adguard/translations/de.json @@ -17,7 +17,7 @@ "host": "Host", "password": "Passwort", "port": "Port", - "ssl": "AdGuard Home verwendet ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, diff --git a/homeassistant/components/advantage_air/translations/de.json b/homeassistant/components/advantage_air/translations/de.json index d3eb0296847..0d3ead73fc0 100644 --- a/homeassistant/components/advantage_air/translations/de.json +++ b/homeassistant/components/advantage_air/translations/de.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "ip_address": "IP Adresse", + "ip_address": "IP-Adresse", "port": "Port" }, "description": "Anschluss an die API deines Advantage Air Wandtabletts.", diff --git a/homeassistant/components/airvisual/translations/de.json b/homeassistant/components/airvisual/translations/de.json index d5b9fd915d3..c6d00ea1375 100644 --- a/homeassistant/components/airvisual/translations/de.json +++ b/homeassistant/components/airvisual/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Diese Koordinaten oder Node/Pro ID sind bereits registriert.", + "already_configured": "Diese Node/Pro ID oder Standort ist bereits konfiguriert.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { @@ -40,7 +40,7 @@ }, "reauth_confirm": { "data": { - "api_key": "API-Key" + "api_key": "API-Schl\u00fcssel" }, "title": "AirVisual erneut authentifizieren" }, diff --git a/homeassistant/components/airvisual/translations/sensor.de.json b/homeassistant/components/airvisual/translations/sensor.de.json new file mode 100644 index 00000000000..d6aeab515bd --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.de.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Kohlenmonoxid", + "n2": "Stickstoffdioxid", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2,5", + "s2": "Schwefeldioxid" + }, + "airvisual__pollutant_level": { + "good": "Gut", + "hazardous": "Gef\u00e4hrlich", + "moderate": "M\u00e4\u00dfig", + "unhealthy": "Ungesund", + "unhealthy_sensitive": "Ungesund f\u00fcr sensible Gruppen", + "very_unhealthy": "Sehr ungesund" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/de.json b/homeassistant/components/ambiclimate/translations/de.json index d91fc15f37d..3f4537a5d5c 100644 --- a/homeassistant/components/ambiclimate/translations/de.json +++ b/homeassistant/components/ambiclimate/translations/de.json @@ -6,7 +6,7 @@ "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen." }, "create_entry": { - "default": "Erfolgreiche Authentifizierung mit Ambiclimate" + "default": "Erfolgreich authentifiziert" }, "error": { "follow_link": "Bitte folge dem Link und authentifizieren dich, bevor du auf Senden klickst", diff --git a/homeassistant/components/ambient_station/translations/de.json b/homeassistant/components/ambient_station/translations/de.json index c6570fee0e3..8dda644cc26 100644 --- a/homeassistant/components/ambient_station/translations/de.json +++ b/homeassistant/components/ambient_station/translations/de.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "API Schl\u00fcssel", + "api_key": "API-Schl\u00fcssel", "app_key": "Anwendungsschl\u00fcssel" }, "title": "Gib deine Informationen ein" diff --git a/homeassistant/components/atag/translations/de.json b/homeassistant/components/atag/translations/de.json index 8d91f5b62fa..976faaa370d 100644 --- a/homeassistant/components/atag/translations/de.json +++ b/homeassistant/components/atag/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Ger\u00e4t wurde bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/binary_sensor/translations/de.json b/homeassistant/components/binary_sensor/translations/de.json index a78befb7965..a2ef817bedb 100644 --- a/homeassistant/components/binary_sensor/translations/de.json +++ b/homeassistant/components/binary_sensor/translations/de.json @@ -139,8 +139,8 @@ "on": "Nass" }, "motion": { - "off": "Ruhig", - "on": "Bewegung erkannt" + "off": "Normal", + "on": "Erkannt" }, "moving": { "off": "Bewegt sich nicht", @@ -171,16 +171,16 @@ "on": "Unsicher" }, "smoke": { - "off": "OK", - "on": "Rauch erkannt" + "off": "Normal", + "on": "Erkannt" }, "sound": { - "off": "Stille", - "on": "Ger\u00e4usch erkannt" + "off": "Normal", + "on": "Erkannt" }, "vibration": { "off": "Normal", - "on": "Vibration" + "on": "Erkannt" }, "window": { "off": "Geschlossen", diff --git a/homeassistant/components/blebox/translations/de.json b/homeassistant/components/blebox/translations/de.json index bb1f9e9c443..c104a96fe46 100644 --- a/homeassistant/components/blebox/translations/de.json +++ b/homeassistant/components/blebox/translations/de.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "IP Adresse", + "host": "IP-Adresse", "port": "Port" }, "description": "Richte deine BleBox f\u00fcr die Integration mit dem Home Assistant ein.", diff --git a/homeassistant/components/bond/translations/de.json b/homeassistant/components/bond/translations/de.json index 934e166e0d5..51f0bd0bee4 100644 --- a/homeassistant/components/bond/translations/de.json +++ b/homeassistant/components/bond/translations/de.json @@ -19,7 +19,7 @@ }, "user": { "data": { - "access_token": "Zugriffstoken", + "access_token": "Zugangstoken", "host": "Host" } } diff --git a/homeassistant/components/broadlink/translations/de.json b/homeassistant/components/broadlink/translations/de.json index e0e819e2140..1e5635b3145 100644 --- a/homeassistant/components/broadlink/translations/de.json +++ b/homeassistant/components/broadlink/translations/de.json @@ -4,13 +4,13 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "not_supported": "Ger\u00e4t nicht unterst\u00fctzt", "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "unknown": "Unerwarteter Fehler" }, "flow_title": "{name} ({model} unter {host})", diff --git a/homeassistant/components/brother/translations/de.json b/homeassistant/components/brother/translations/de.json index 7b9f811ac32..b79ff0a7619 100644 --- a/homeassistant/components/brother/translations/de.json +++ b/homeassistant/components/brother/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieser Drucker ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "unsupported_model": "Dieses Druckermodell wird nicht unterst\u00fctzt." }, "error": { diff --git a/homeassistant/components/bsblan/translations/de.json b/homeassistant/components/bsblan/translations/de.json index ce9d8a0cb00..079749f0f7a 100644 --- a/homeassistant/components/bsblan/translations/de.json +++ b/homeassistant/components/bsblan/translations/de.json @@ -13,7 +13,7 @@ "host": "Host", "passkey": "Passkey String", "password": "Passwort", - "port": "Port Nummer", + "port": "Port", "username": "Benutzername" }, "description": "Richte dein BSB-Lan Ger\u00e4t f\u00fcr die Integration mit dem Home Assistant ein.", diff --git a/homeassistant/components/cast/translations/de.json b/homeassistant/components/cast/translations/de.json index 3e03e6a3b73..b337a8575e0 100644 --- a/homeassistant/components/cast/translations/de.json +++ b/homeassistant/components/cast/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "invalid_known_hosts": "Bekannte Hosts m\u00fcssen eine durch Kommata getrennte Liste von Hosts sein." diff --git a/homeassistant/components/cert_expiry/translations/de.json b/homeassistant/components/cert_expiry/translations/de.json index 2c01c9f71a6..640e715b359 100644 --- a/homeassistant/components/cert_expiry/translations/de.json +++ b/homeassistant/components/cert_expiry/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Diese Kombination aus Host und Port ist bereits konfiguriert.", + "already_configured": "Der Dienst ist bereits konfiguriert", "import_failed": "Import aus Konfiguration fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/cloudflare/translations/de.json b/homeassistant/components/cloudflare/translations/de.json index d03f293b38b..98cdbe355f6 100644 --- a/homeassistant/components/cloudflare/translations/de.json +++ b/homeassistant/components/cloudflare/translations/de.json @@ -26,7 +26,7 @@ }, "user": { "data": { - "api_token": "API Token" + "api_token": "API-Token" }, "description": "F\u00fcr diese Integration ist ein API-Token erforderlich, der mit Zone: Zone: Lesen und Zone: DNS: Bearbeiten f\u00fcr alle Zonen in deinem Konto erstellt wurde.", "title": "Mit Cloudflare verbinden" diff --git a/homeassistant/components/co2signal/translations/de.json b/homeassistant/components/co2signal/translations/de.json new file mode 100644 index 00000000000..e35b991566f --- /dev/null +++ b/homeassistant/components/co2signal/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "api_ratelimit": "API Ratelimit \u00fcberschritten", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "api_ratelimit": "API Ratelimit \u00fcberschritten", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + } + }, + "country": { + "data": { + "country_code": "L\u00e4ndercode" + } + }, + "user": { + "data": { + "api_key": "Zugangstoken", + "location": "Daten abrufen f\u00fcr" + }, + "description": "Besuche https://co2signal.com/, um ein Token anzufordern." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/de.json b/homeassistant/components/coinbase/translations/de.json index e1fb1fdbcad..25d20fe8cf2 100644 --- a/homeassistant/components/coinbase/translations/de.json +++ b/homeassistant/components/coinbase/translations/de.json @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "Zu meldende Wallet-Guthaben.", + "exchange_base": "Basisw\u00e4hrung f\u00fcr Wechselkurssensoren.", "exchange_rate_currencies": "Zu meldende Wechselkurse." }, "description": "Coinbase-Optionen anpassen" diff --git a/homeassistant/components/control4/translations/de.json b/homeassistant/components/control4/translations/de.json index e50e2499320..4c9ef9abf11 100644 --- a/homeassistant/components/control4/translations/de.json +++ b/homeassistant/components/control4/translations/de.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "IP-Addresse", + "host": "IP-Adresse", "password": "Passwort", "username": "Benutzername" }, diff --git a/homeassistant/components/coronavirus/translations/de.json b/homeassistant/components/coronavirus/translations/de.json index 25a1cf44ca5..24da7b952ea 100644 --- a/homeassistant/components/coronavirus/translations/de.json +++ b/homeassistant/components/coronavirus/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Land ist bereits konfiguriert.", + "already_configured": "Der Dienst ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "step": { diff --git a/homeassistant/components/daikin/translations/de.json b/homeassistant/components/daikin/translations/de.json index dcec53c1569..038a997201a 100644 --- a/homeassistant/components/daikin/translations/de.json +++ b/homeassistant/components/daikin/translations/de.json @@ -16,7 +16,7 @@ "host": "Host", "password": "Passwort" }, - "description": "Gib die IP-Adresse deiner Daikin AC ein.", + "description": "Gib die IP-Adresse deiner Daikin AC ein.\n\nBeachte, dass API-Schl\u00fcssel und Passwort nur von BRP072Cxx bzw. SKYFi-Ger\u00e4ten verwendet werden.", "title": "Daikin AC konfigurieren" } } diff --git a/homeassistant/components/dexcom/translations/de.json b/homeassistant/components/dexcom/translations/de.json index 20e5ee22751..be04c779390 100644 --- a/homeassistant/components/dexcom/translations/de.json +++ b/homeassistant/components/dexcom/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/directv/translations/de.json b/homeassistant/components/directv/translations/de.json index c4a6ed1791e..5f06a68e715 100644 --- a/homeassistant/components/directv/translations/de.json +++ b/homeassistant/components/directv/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "unknown": "Unerwarteter Fehler" }, "error": { diff --git a/homeassistant/components/doorbird/translations/de.json b/homeassistant/components/doorbird/translations/de.json index b558bd0b222..3f025e67386 100644 --- a/homeassistant/components/doorbird/translations/de.json +++ b/homeassistant/components/doorbird/translations/de.json @@ -7,7 +7,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifikation", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, "flow_title": "{name} ({host})", diff --git a/homeassistant/components/dunehd/translations/de.json b/homeassistant/components/dunehd/translations/de.json index aa87de530b8..f3d7ecd725a 100644 --- a/homeassistant/components/dunehd/translations/de.json +++ b/homeassistant/components/dunehd/translations/de.json @@ -6,7 +6,7 @@ "error": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse." + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse" }, "step": { "user": { diff --git a/homeassistant/components/ecobee/translations/de.json b/homeassistant/components/ecobee/translations/de.json index 0c89a696b2c..10edbd4ecd1 100644 --- a/homeassistant/components/ecobee/translations/de.json +++ b/homeassistant/components/ecobee/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits eingerichtet. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "pin_request_failed": "Fehler beim Anfordern der PIN von ecobee; Bitte \u00fcberpr\u00fcfe, ob der API-Schl\u00fcssel korrekt ist.", @@ -14,7 +14,7 @@ }, "user": { "data": { - "api_key": "API Schl\u00fcssel" + "api_key": "API-Schl\u00fcssel" }, "description": "Bitte gib den von ecobee.com erhaltenen API-Schl\u00fcssel ein.", "title": "ecobee API-Schl\u00fcssel" diff --git a/homeassistant/components/elgato/translations/de.json b/homeassistant/components/elgato/translations/de.json index 95bb2609d84..6ff531919cb 100644 --- a/homeassistant/components/elgato/translations/de.json +++ b/homeassistant/components/elgato/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Elgato Key Light-Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/enocean/translations/de.json b/homeassistant/components/enocean/translations/de.json index fe7467fbf09..63a3cf73ca8 100644 --- a/homeassistant/components/enocean/translations/de.json +++ b/homeassistant/components/enocean/translations/de.json @@ -2,7 +2,7 @@ "config": { "abort": { "invalid_dongle_path": "Ung\u00fcltiger Dongle-Pfad", - "single_instance_allowed": "Schon konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "invalid_dongle_path": "Kein g\u00fcltiger Dongle unter diesem Pfad gefunden" diff --git a/homeassistant/components/esphome/translations/de.json b/homeassistant/components/esphome/translations/de.json index c82afc78851..8084ef26f0e 100644 --- a/homeassistant/components/esphome/translations/de.json +++ b/homeassistant/components/esphome/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "ESP ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt" }, "error": { diff --git a/homeassistant/components/fireservicerota/translations/de.json b/homeassistant/components/fireservicerota/translations/de.json index 737fbc5ff53..c8c18c4c372 100644 --- a/homeassistant/components/fireservicerota/translations/de.json +++ b/homeassistant/components/fireservicerota/translations/de.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "Account wurde schon konfiguriert", - "reauth_successful": "Neuauthentifizierung erfolgreich" + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "create_entry": { - "default": "Authentifizierung erfolgreich" + "default": "Erfolgreich authentifiziert" }, "error": { - "invalid_auth": "Authentifizienung ung\u00fcltig" + "invalid_auth": "Ung\u00fcltige Authentifizierung" }, "step": { "reauth": { @@ -21,7 +21,7 @@ "data": { "password": "Passwort", "url": "Webseite", - "username": "Nutzername" + "username": "Benutzername" } } } diff --git a/homeassistant/components/flick_electric/translations/de.json b/homeassistant/components/flick_electric/translations/de.json index 13ae8555608..8409250c5fa 100644 --- a/homeassistant/components/flick_electric/translations/de.json +++ b/homeassistant/components/flick_electric/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/flipr/translations/ca.json b/homeassistant/components/flipr/translations/ca.json new file mode 100644 index 00000000000..fcb43623030 --- /dev/null +++ b/homeassistant/components/flipr/translations/ca.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "no_flipr_id_found": "De moment, no hi ha cap identificador de Flipr associat al teu compte. Primer hauries de verificar que funciona amb l'aplicaci\u00f3 m\u00f2bil de Flipr.", + "unknown": "Error inesperat" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Tria l'ID Flipr de la llista", + "title": "Tria el teu Flipr" + }, + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "description": "Connecta't amb el teu compte de Flipr.", + "title": "Connexi\u00f3 amb Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/en.json b/homeassistant/components/flipr/translations/en.json index 017514e147c..667824d407b 100644 --- a/homeassistant/components/flipr/translations/en.json +++ b/homeassistant/components/flipr/translations/en.json @@ -1,30 +1,30 @@ { "config": { "abort": { - "already_configured": "This Flipr is already configured" + "already_configured": "Device is already configured" }, "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error", - "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first." + "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first.", + "unknown": "Unexpected error" }, "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Choose your Flipr ID in the list", + "title": "Choose your Flipr" + }, "user": { "data": { "email": "Email", "password": "Password" }, - "description": "Connect to your flipr account", - "title": "Flipr device" - }, - "flipr_id": { - "data": { - "flipr_id": "Flipr ID" - }, - "description": "Choose your flipr ID in the list", - "title": "Flipr device" + "description": "Connect using your Flipr account.", + "title": "Connect to Flipr" } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/et.json b/homeassistant/components/flipr/translations/et.json new file mode 100644 index 00000000000..46be2f4378f --- /dev/null +++ b/homeassistant/components/flipr/translations/et.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "no_flipr_id_found": "Kontoga pole praegu \u00fchtegi flipr-it seostatud. K\u00f5igepealt pead kontrollima, kas see t\u00f6\u00f6tab Flipri mobiilirakendusega.", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipri ID" + }, + "description": "Vali loendist oma Flipri ID", + "title": "Vali oma Flipr" + }, + "user": { + "data": { + "email": "E-post", + "password": "Salas\u00f5na" + }, + "description": "\u00dchenda oma Flipr konto abil.", + "title": "Flipriga \u00fchenduse loomine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/de.json b/homeassistant/components/forked_daapd/translations/de.json index a475becc605..51fd312fd6d 100644 --- a/homeassistant/components/forked_daapd/translations/de.json +++ b/homeassistant/components/forked_daapd/translations/de.json @@ -6,7 +6,7 @@ }, "error": { "forbidden": "Verbindung kann nicht hergestellt werden. Bitte \u00fcberpr\u00fcfe deine forked-daapd-Netzwerkberechtigungen.", - "unknown_error": "Unbekannter Fehler", + "unknown_error": "Unerwarteter Fehler", "websocket_not_enabled": "Forked-Daapd-Server-Websocket nicht aktiviert.", "wrong_host_or_port": "Verbindung konnte nicht hergestellt werden. Bitte Host und Port pr\u00fcfen.", "wrong_password": "Ung\u00fcltiges Passwort", diff --git a/homeassistant/components/fritzbox/translations/de.json b/homeassistant/components/fritzbox/translations/de.json index ceaca6fd19a..7da8e616cfc 100644 --- a/homeassistant/components/fritzbox/translations/de.json +++ b/homeassistant/components/fritzbox/translations/de.json @@ -8,7 +8,7 @@ "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { - "invalid_auth": "Ung\u00fcltige Zugangsdaten" + "invalid_auth": "Ung\u00fcltige Authentifizierung" }, "flow_title": "AVM FRITZ!Box: {name}", "step": { diff --git a/homeassistant/components/garmin_connect/translations/de.json b/homeassistant/components/garmin_connect/translations/de.json index 7817a44f6c0..d6310595ad8 100644 --- a/homeassistant/components/garmin_connect/translations/de.json +++ b/homeassistant/components/garmin_connect/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Konto ist bereits konfiguriert." + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/geonetnz_quakes/translations/de.json b/homeassistant/components/geonetnz_quakes/translations/de.json index 583712c6c4e..2bfc3f2dbbd 100644 --- a/homeassistant/components/geonetnz_quakes/translations/de.json +++ b/homeassistant/components/geonetnz_quakes/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Der Standort ist bereits konfiguriert." + "already_configured": "Der Dienst ist bereits konfiguriert" }, "step": { "user": { diff --git a/homeassistant/components/gios/translations/de.json b/homeassistant/components/gios/translations/de.json index e1351278f38..99548187601 100644 --- a/homeassistant/components/gios/translations/de.json +++ b/homeassistant/components/gios/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "GIO\u015a integration f\u00fcr diese Messstation ist bereits konfiguriert. " + "already_configured": "Standort ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/glances/translations/de.json b/homeassistant/components/glances/translations/de.json index e464bfdee34..8c91e4fb2e3 100644 --- a/homeassistant/components/glances/translations/de.json +++ b/homeassistant/components/glances/translations/de.json @@ -14,9 +14,9 @@ "name": "Name", "password": "Passwort", "port": "Port", - "ssl": "Verwende SSL / TLS, um eine Verbindung zum Glances-System herzustellen", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "\u00dcberpr\u00fcfe die Zertifizierung des Systems", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen", "version": "Glances API-Version (2 oder 3)" }, "title": "Glances einrichten" diff --git a/homeassistant/components/goalzero/translations/de.json b/homeassistant/components/goalzero/translations/de.json index d483564fa72..5133488c247 100644 --- a/homeassistant/components/goalzero/translations/de.json +++ b/homeassistant/components/goalzero/translations/de.json @@ -7,7 +7,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/growatt_server/translations/ca.json b/homeassistant/components/growatt_server/translations/ca.json index 0c1e1b6cb83..39dc1153434 100644 --- a/homeassistant/components/growatt_server/translations/ca.json +++ b/homeassistant/components/growatt_server/translations/ca.json @@ -17,6 +17,7 @@ "data": { "name": "Nom", "password": "Contrasenya", + "url": "URL", "username": "Nom d'usuari" }, "title": "Introdueix la teva informaci\u00f3 de Growatt" diff --git a/homeassistant/components/growatt_server/translations/de.json b/homeassistant/components/growatt_server/translations/de.json index ae24396823a..adb769baa2d 100644 --- a/homeassistant/components/growatt_server/translations/de.json +++ b/homeassistant/components/growatt_server/translations/de.json @@ -17,6 +17,7 @@ "data": { "name": "Name", "password": "Passwort", + "url": "URL", "username": "Benutzername" }, "title": "Gib deine Growatt-Informationen ein" diff --git a/homeassistant/components/growatt_server/translations/en.json b/homeassistant/components/growatt_server/translations/en.json index 5461c822320..86196783133 100644 --- a/homeassistant/components/growatt_server/translations/en.json +++ b/homeassistant/components/growatt_server/translations/en.json @@ -17,6 +17,7 @@ "data": { "name": "Name", "password": "Password", + "url": "URL", "username": "Username" }, "title": "Enter your Growatt information" diff --git a/homeassistant/components/growatt_server/translations/et.json b/homeassistant/components/growatt_server/translations/et.json index 3115713bc68..c3327e3d676 100644 --- a/homeassistant/components/growatt_server/translations/et.json +++ b/homeassistant/components/growatt_server/translations/et.json @@ -17,6 +17,7 @@ "data": { "name": "Nimi", "password": "Salas\u00f5na", + "url": "URL", "username": "Kasutajanimi" }, "title": "Sisesta oma Growatti teave" diff --git a/homeassistant/components/growatt_server/translations/ru.json b/homeassistant/components/growatt_server/translations/ru.json index 7d866ba09b0..0b98838dac8 100644 --- a/homeassistant/components/growatt_server/translations/ru.json +++ b/homeassistant/components/growatt_server/translations/ru.json @@ -17,6 +17,7 @@ "data": { "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "url": "URL-\u0430\u0434\u0440\u0435\u0441", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, "title": "\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 Growatt." diff --git a/homeassistant/components/hangouts/translations/de.json b/homeassistant/components/hangouts/translations/de.json index 7b888cf531e..42770308346 100644 --- a/homeassistant/components/hangouts/translations/de.json +++ b/homeassistant/components/hangouts/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Google Hangouts ist bereits konfiguriert", + "already_configured": "Der Dienst ist bereits konfiguriert", "unknown": "Unerwarteter Fehler" }, "error": { @@ -20,7 +20,7 @@ "user": { "data": { "authorization_code": "Autorisierungscode (f\u00fcr die manuelle Authentifizierung erforderlich)", - "email": "E-Mail-Adresse", + "email": "E-Mail", "password": "Passwort" }, "description": "Leer", diff --git a/homeassistant/components/hisense_aehw4a1/translations/de.json b/homeassistant/components/hisense_aehw4a1/translations/de.json index 7c0bd96a9c9..03e15051eb0 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/de.json +++ b/homeassistant/components/hisense_aehw4a1/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine Hisense AEH-W4A1-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/homematicip_cloud/translations/de.json b/homeassistant/components/homematicip_cloud/translations/de.json index 1da1e06c0fb..3cb74491c7f 100644 --- a/homeassistant/components/homematicip_cloud/translations/de.json +++ b/homeassistant/components/homematicip_cloud/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Der Accesspoint ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "connection_aborted": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler" }, diff --git a/homeassistant/components/honeywell/translations/de.json b/homeassistant/components/honeywell/translations/de.json new file mode 100644 index 00000000000..a146d442eef --- /dev/null +++ b/homeassistant/components/honeywell/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Bitte gib die Anmeldedaten ein, mit denen du dich bei mytotalconnectcomfort.com anmeldest.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/de.json b/homeassistant/components/hue/translations/de.json index 122e1ba6f5c..bf0d2a7c756 100644 --- a/homeassistant/components/hue/translations/de.json +++ b/homeassistant/components/hue/translations/de.json @@ -8,7 +8,7 @@ "discover_timeout": "Es k\u00f6nnen keine Hue Bridges erkannt werden", "no_bridges": "Keine Philips Hue Bridges erkannt", "not_hue_bridge": "Keine Philips Hue Bridge entdeckt", - "unknown": "Unbekannter Fehler ist aufgetreten" + "unknown": "Unerwarteter Fehler" }, "error": { "linking": "Unerwarteter Fehler", diff --git a/homeassistant/components/icloud/translations/de.json b/homeassistant/components/icloud/translations/de.json index 4cc7ed93eef..207735018f0 100644 --- a/homeassistant/components/icloud/translations/de.json +++ b/homeassistant/components/icloud/translations/de.json @@ -28,7 +28,7 @@ "user": { "data": { "password": "Passwort", - "username": "Email", + "username": "E-Mail", "with_family": "Mit Familie" }, "description": "Gib deine Zugangsdaten ein", diff --git a/homeassistant/components/iqvia/translations/de.json b/homeassistant/components/iqvia/translations/de.json index 1318a9c90cc..5d307eea829 100644 --- a/homeassistant/components/iqvia/translations/de.json +++ b/homeassistant/components/iqvia/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dienst ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "invalid_zip_code": "Postleitzahl ist ung\u00fcltig" diff --git a/homeassistant/components/izone/translations/de.json b/homeassistant/components/izone/translations/de.json index f6e03c3af27..6b9441d1683 100644 --- a/homeassistant/components/izone/translations/de.json +++ b/homeassistant/components/izone/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine iZone-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/kodi/translations/de.json b/homeassistant/components/kodi/translations/de.json index 9e486ed119b..478fa2c32e5 100644 --- a/homeassistant/components/kodi/translations/de.json +++ b/homeassistant/components/kodi/translations/de.json @@ -29,7 +29,7 @@ "data": { "host": "Host", "port": "Port", - "ssl": "Verwendet ein SSL Zertifikat" + "ssl": "Verwendet ein SSL-Zertifikat" }, "description": "Kodi-Verbindungsinformationen. Bitte stelle sicher, dass du \"Steuerung von Kodi \u00fcber HTTP zulassen\" in System/Einstellungen/Netzwerk/Dienste aktiviert hast." }, diff --git a/homeassistant/components/life360/translations/de.json b/homeassistant/components/life360/translations/de.json index 7e495987b45..516b0255349 100644 --- a/homeassistant/components/life360/translations/de.json +++ b/homeassistant/components/life360/translations/de.json @@ -8,7 +8,7 @@ "default": "M\u00f6gliche erweiterte Einstellungen finden sich unter [Life360-Dokumentation]({docs_url})." }, "error": { - "already_configured": "Konto ist bereits konfiguriert", + "already_configured": "Konto wurde bereits konfiguriert", "invalid_auth": "Ung\u00fcltige Authentifizierung", "invalid_username": "Ung\u00fcltiger Benutzername", "unknown": "Unerwarteter Fehler" diff --git a/homeassistant/components/lifx/translations/de.json b/homeassistant/components/lifx/translations/de.json index 83eded1ddc6..0c619ea4062 100644 --- a/homeassistant/components/lifx/translations/de.json +++ b/homeassistant/components/lifx/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Keine LIFX Ger\u00e4te im Netzwerk gefunden", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/litterrobot/translations/de.json b/homeassistant/components/litterrobot/translations/de.json index c8f4f35716e..14f319fb4d3 100644 --- a/homeassistant/components/litterrobot/translations/de.json +++ b/homeassistant/components/litterrobot/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/melcloud/translations/de.json b/homeassistant/components/melcloud/translations/de.json index a0d6ce662ba..e983af740e2 100644 --- a/homeassistant/components/melcloud/translations/de.json +++ b/homeassistant/components/melcloud/translations/de.json @@ -12,7 +12,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "description": "Verbinde dich mit deinem MELCloud-Konto.", "title": "Stelle eine Verbindung zu MELCloud her" diff --git a/homeassistant/components/metoffice/translations/de.json b/homeassistant/components/metoffice/translations/de.json index 8f35c2aaeaa..2c28d0742ce 100644 --- a/homeassistant/components/metoffice/translations/de.json +++ b/homeassistant/components/metoffice/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Service ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "API Key", + "api_key": "API-Schl\u00fcssel", "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad" }, diff --git a/homeassistant/components/mill/translations/de.json b/homeassistant/components/mill/translations/de.json index 63b6b7ea6e9..44d9c2448e6 100644 --- a/homeassistant/components/mill/translations/de.json +++ b/homeassistant/components/mill/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Account ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" diff --git a/homeassistant/components/mqtt/translations/de.json b/homeassistant/components/mqtt/translations/de.json index 132b4c42e18..2961a69ed1b 100644 --- a/homeassistant/components/mqtt/translations/de.json +++ b/homeassistant/components/mqtt/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" diff --git a/homeassistant/components/neato/translations/de.json b/homeassistant/components/neato/translations/de.json index 4b0722a207c..c7fd239c585 100644 --- a/homeassistant/components/neato/translations/de.json +++ b/homeassistant/components/neato/translations/de.json @@ -4,7 +4,7 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", - "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler sind [im Hilfebereich]({docs_url}) zu finden", + "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler findest du [im Hilfebereich]({docs_url}).", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "create_entry": { diff --git a/homeassistant/components/netatmo/translations/de.json b/homeassistant/components/netatmo/translations/de.json index e1a2c3c93cc..becff2df430 100644 --- a/homeassistant/components/netatmo/translations/de.json +++ b/homeassistant/components/netatmo/translations/de.json @@ -7,11 +7,11 @@ "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "create_entry": { - "default": "Erfolgreich authentifiziert." + "default": "Erfolgreich authentifiziert" }, "step": { "pick_implementation": { - "title": "W\u00e4hle Authentifizierungs-Methode" + "title": "W\u00e4hle die Authentifizierungsmethode" } } }, diff --git a/homeassistant/components/nfandroidtv/translations/ca.json b/homeassistant/components/nfandroidtv/translations/ca.json new file mode 100644 index 00000000000..861ad41a39b --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom" + }, + "description": "Aquesta integraci\u00f3 necessita l'aplicaci\u00f3 Notificacions per a 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\nHauries de configurar o b\u00e9 una reserva DHCP al router (consulta el manual del teu rounter) o b\u00e9 adre\u00e7a IP est\u00e0tica al dispositiu. Si no o fas, el disositiu acabar\u00e0 deixant d'estar disponible.", + "title": "Notificacions per a Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/de.json b/homeassistant/components/nfandroidtv/translations/de.json new file mode 100644 index 00000000000..b3adce9ac07 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Diese Integration erfordert die App \"Benachrichtigungen f\u00fcr Android TV\".\n\nF\u00fcr Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nF\u00fcr Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDu solltest entweder eine DHCP-Reservierung auf deinem Router (siehe Benutzerhandbuch deines Routers) oder eine statische IP-Adresse auf dem Ger\u00e4t einrichten. Andernfalls wird das Ger\u00e4t irgendwann nicht mehr verf\u00fcgbar sein.", + "title": "Benachrichtigungen f\u00fcr Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/en.json b/homeassistant/components/nfandroidtv/translations/en.json index 22d014c1ffa..f117428df35 100644 --- a/homeassistant/components/nfandroidtv/translations/en.json +++ b/homeassistant/components/nfandroidtv/translations/en.json @@ -18,4 +18,4 @@ } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/et.json b/homeassistant/components/nfandroidtv/translations/et.json new file mode 100644 index 00000000000..f2405ab1421 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nimi" + }, + "description": "See sidumine n\u00f5uab Android TV rakenduse Notifications for Android TV kasutamist.\n\nAndroid TV jaoks: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFire TV jaoks: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nPead seadma ruuterile kas DHCP-reservatsiooni (vt ruuteri kasutusjuhendit) v\u00f5i seadme staatilise IP-aadressi. Vastasel juhul muutub seade l\u00f5puks k\u00e4ttesaamatuks.", + "title": "Android TV / Fire TV teavitused" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/ru.json b/homeassistant/components/nfandroidtv/translations/ru.json new file mode 100644 index 00000000000..ce0d4651dfc --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/ru.json @@ -0,0 +1,21 @@ +{ + "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.", + "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": "\u0414\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \"Notifications for Android TV\". \n\n\u0414\u043b\u044f Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\n\u0414\u043b\u044f Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\n\u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 DHCP \u043d\u0430 \u0432\u0430\u0448\u0435\u043c \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435 (\u0441\u043c\u043e\u0442\u0440\u0438\u0442\u0435 \u0440\u0443\u043a\u043e\u0432\u043e\u0434\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0443) \u0438\u043b\u0438 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435. \u0412 \u043f\u0440\u043e\u0442\u0438\u0432\u043d\u043e\u043c \u0441\u043b\u0443\u0447\u0430\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043c\u043e\u0436\u0435\u0442 \u0441\u0442\u0430\u0442\u044c \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c.", + "title": "Notifications for Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/de.json b/homeassistant/components/nmap_tracker/translations/de.json index 1893eb28b08..729a964059f 100644 --- a/homeassistant/components/nmap_tracker/translations/de.json +++ b/homeassistant/components/nmap_tracker/translations/de.json @@ -27,7 +27,7 @@ "data": { "exclude": "Netzwerkadressen (kommagetrennt), die von der \u00dcberpr\u00fcfung ausgeschlossen werden sollen", "home_interval": "Mindestanzahl von Minuten zwischen den Scans aktiver Ger\u00e4te (Batterie schonen)", - "hosts": "Zu scannende Netzwerkadressen (kommagetrennt)", + "hosts": "Netzwerkadressen (kommagetrennt) zum Scannen", "interval_seconds": "Scanintervall", "scan_options": "Raw konfigurierbare Scan-Optionen f\u00fcr Nmap", "track_new_devices": "Neue Ger\u00e4te verfolgen" diff --git a/homeassistant/components/nzbget/translations/de.json b/homeassistant/components/nzbget/translations/de.json index 74d073ce292..1b1575769bc 100644 --- a/homeassistant/components/nzbget/translations/de.json +++ b/homeassistant/components/nzbget/translations/de.json @@ -15,9 +15,9 @@ "name": "Name", "password": "Passwort", "port": "Port", - "ssl": "Nutzt ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "SSL-Zertifikat verfizieren" + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, "title": "Mit NZBGet verbinden" } diff --git a/homeassistant/components/openweathermap/translations/de.json b/homeassistant/components/openweathermap/translations/de.json index 7b2806693f0..615a642a859 100644 --- a/homeassistant/components/openweathermap/translations/de.json +++ b/homeassistant/components/openweathermap/translations/de.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "API Key", + "api_key": "API-Schl\u00fcssel", "language": "Sprache", "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad", diff --git a/homeassistant/components/pi_hole/translations/de.json b/homeassistant/components/pi_hole/translations/de.json index 6d9518490d5..40a5db3c21f 100644 --- a/homeassistant/components/pi_hole/translations/de.json +++ b/homeassistant/components/pi_hole/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Service ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" @@ -16,10 +16,10 @@ "data": { "api_key": "API-Schl\u00fcssel", "host": "Host", - "location": "Org", + "location": "Standort", "name": "Name", "port": "Port", - "ssl": "Nutzt ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "statistics_only": "Nur Statistiken", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" } diff --git a/homeassistant/components/plex/translations/de.json b/homeassistant/components/plex/translations/de.json index ba2a2c52229..130d34505d2 100644 --- a/homeassistant/components/plex/translations/de.json +++ b/homeassistant/components/plex/translations/de.json @@ -21,7 +21,7 @@ "data": { "host": "Host", "port": "Port", - "ssl": "SSL verwenden", + "ssl": "Verwendet ein SSL-Zertifikat", "token": "Token (optional)", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, diff --git a/homeassistant/components/plugwise/translations/de.json b/homeassistant/components/plugwise/translations/de.json index 4e4bc8baeee..a5c11645d6f 100644 --- a/homeassistant/components/plugwise/translations/de.json +++ b/homeassistant/components/plugwise/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Service ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/point/translations/de.json b/homeassistant/components/point/translations/de.json index c2764e08da9..f0c2eee923b 100644 --- a/homeassistant/components/point/translations/de.json +++ b/homeassistant/components/point/translations/de.json @@ -12,7 +12,7 @@ }, "error": { "follow_link": "Bitte folgen dem Link und authentifiziere dich, bevor du auf Senden klickst", - "no_token": "Ung\u00fcltiger Access Token" + "no_token": "Ung\u00fcltiger Zugriffs-Token" }, "step": { "auth": { diff --git a/homeassistant/components/ps4/translations/de.json b/homeassistant/components/ps4/translations/de.json index 59a84e7ad27..ca1a0ab24b1 100644 --- a/homeassistant/components/ps4/translations/de.json +++ b/homeassistant/components/ps4/translations/de.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "credential_error": "Fehler beim Abrufen der Anmeldeinformationen.", - "no_devices_found": "Es wurden keine PlayStation 4 im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "port_987_bind_error": "Konnte sich nicht an Port 987 binden. Weitere Informationen findest du in der [Dokumentation] (https://www.home-assistant.io/components/ps4/).", "port_997_bind_error": "Bind to Port 997 nicht m\u00f6glich. Weitere Informationen findest du in der [Dokumentation](https://www.home-assistant.io/components/ps4/)" }, @@ -25,7 +25,7 @@ "name": "Name", "region": "Region" }, - "description": "Gib deine PlayStation 4-Informationen ein. Navigiere f\u00fcr \"PIN\" auf der PlayStation 4-Konsole zu \"Einstellungen\". Navigiere dann zu \"Mobile App-Verbindungseinstellungen\" und w\u00e4hle \"Ger\u00e4t hinzuf\u00fcgen\" aus. Gib die angezeigte PIN ein. Weitere Informationen findest du in der [Dokumentation](https://www.home-assistant.io/components/ps4/).", + "description": "Gib deine PlayStation 4-Informationen ein. Navigiere f\u00fcr den PIN-Code auf der PlayStation 4-Konsole zu \"Einstellungen\". Navigiere dann zu \"Mobile App-Verbindungseinstellungen\" und w\u00e4hle \"Ger\u00e4t hinzuf\u00fcgen\" aus. Gib die angezeigte PIN-Code ein. Weitere Informationen findest du in der [Dokumentation](https://www.home-assistant.io/components/ps4/).", "title": "PlayStation 4" }, "mode": { diff --git a/homeassistant/components/rfxtrx/translations/de.json b/homeassistant/components/rfxtrx/translations/de.json index a806afb6dbf..7b006782d96 100644 --- a/homeassistant/components/rfxtrx/translations/de.json +++ b/homeassistant/components/rfxtrx/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert. Nur eine Konfiguration m\u00f6glich.", + "already_configured": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/roku/translations/de.json b/homeassistant/components/roku/translations/de.json index 5f72b4bdd9b..ce8ec9e4595 100644 --- a/homeassistant/components/roku/translations/de.json +++ b/homeassistant/components/roku/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "unknown": "Unerwarteter Fehler" }, diff --git a/homeassistant/components/samsungtv/translations/de.json b/homeassistant/components/samsungtv/translations/de.json index 5e79709f5bd..f59004a5dab 100644 --- a/homeassistant/components/samsungtv/translations/de.json +++ b/homeassistant/components/samsungtv/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieser Samsung TV ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "auth_missing": "Home Assistant ist nicht berechtigt, eine Verbindung zu diesem Samsung TV herzustellen. \u00dcberpr\u00fcfe den Ger\u00e4teverbindungsmanager in den Einstellungen deines Fernsehger\u00e4ts, um Home Assistant zu autorisieren.", "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/sense/translations/de.json b/homeassistant/components/sense/translations/de.json index 27cfcc5e5dc..df36684c8b4 100644 --- a/homeassistant/components/sense/translations/de.json +++ b/homeassistant/components/sense/translations/de.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "E-Mail-Adresse", + "email": "E-Mail", "password": "Passwort" }, "title": "Stelle eine Verbindung zu deinem Sense Energy Monitor her" diff --git a/homeassistant/components/simplisafe/translations/de.json b/homeassistant/components/simplisafe/translations/de.json index e4966581fac..966f3598d95 100644 --- a/homeassistant/components/simplisafe/translations/de.json +++ b/homeassistant/components/simplisafe/translations/de.json @@ -26,7 +26,7 @@ "data": { "code": "Code (wird in der Benutzeroberfl\u00e4che von Home Assistant verwendet)", "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "title": "Gib deine Informationen ein" } diff --git a/homeassistant/components/smartthings/translations/de.json b/homeassistant/components/smartthings/translations/de.json index cd946ca8261..6cd7157b702 100644 --- a/homeassistant/components/smartthings/translations/de.json +++ b/homeassistant/components/smartthings/translations/de.json @@ -17,7 +17,7 @@ }, "pat": { "data": { - "access_token": "Zugriffs-Token" + "access_token": "Zugangstoken" }, "description": "Bitte gib ein SmartThings [Personal Access Token] ({token_url}) ein, das gem\u00e4\u00df den [Anweisungen] ({component_url}) erstellt wurde. Dies wird zur Erstellung der Home Assistant-Integration in deinem SmartThings-Konto verwendet.", "title": "Gib den pers\u00f6nlichen Zugangstoken an" diff --git a/homeassistant/components/smarttub/translations/de.json b/homeassistant/components/smarttub/translations/de.json index 4549360f761..a529f679868 100644 --- a/homeassistant/components/smarttub/translations/de.json +++ b/homeassistant/components/smarttub/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert", + "already_configured": "Konto wurde bereits konfiguriert", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { diff --git a/homeassistant/components/solaredge/translations/de.json b/homeassistant/components/solaredge/translations/de.json index 20fc557e5c8..247187f35af 100644 --- a/homeassistant/components/solaredge/translations/de.json +++ b/homeassistant/components/solaredge/translations/de.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "could_not_connect": "Es konnte keine Verbindung zur Solaredge-API hergestellt werden", "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", "site_not_active": "Die Seite ist nicht aktiv" diff --git a/homeassistant/components/sonarr/translations/de.json b/homeassistant/components/sonarr/translations/de.json index c7ca7bd692b..9779a985034 100644 --- a/homeassistant/components/sonarr/translations/de.json +++ b/homeassistant/components/sonarr/translations/de.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Der Dienst ist bereits konfiguriert", "reauth_successful": "Die erneute Authentifizierung war erfolgreich", - "unknown": "Unerwateter Fehler" + "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -17,7 +17,7 @@ }, "user": { "data": { - "api_key": "API Schl\u00fcssel", + "api_key": "API-Schl\u00fcssel", "base_path": "Pfad zur API", "host": "Host", "port": "Port", diff --git a/homeassistant/components/songpal/translations/de.json b/homeassistant/components/songpal/translations/de.json index ae1695eaa2d..97ba487f525 100644 --- a/homeassistant/components/songpal/translations/de.json +++ b/homeassistant/components/songpal/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "not_songpal_device": "Kein Songpal-Ger\u00e4t" }, "error": { diff --git a/homeassistant/components/sonos/translations/de.json b/homeassistant/components/sonos/translations/de.json index 3860d56387d..0c799072010 100644 --- a/homeassistant/components/sonos/translations/de.json +++ b/homeassistant/components/sonos/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Keine Sonos Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "not_sonos_device": "Erkanntes Ger\u00e4t ist kein Sonos-Ger\u00e4t", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, diff --git a/homeassistant/components/speedtestdotnet/translations/de.json b/homeassistant/components/speedtestdotnet/translations/de.json index 79a47cbcd2e..eff51fab0b4 100644 --- a/homeassistant/components/speedtestdotnet/translations/de.json +++ b/homeassistant/components/speedtestdotnet/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration m\u00f6glich.", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", "wrong_server_id": "Server-ID ist ung\u00fcltig" }, "step": { diff --git a/homeassistant/components/srp_energy/translations/de.json b/homeassistant/components/srp_energy/translations/de.json index 45f8ed451a4..a7992cac9b1 100644 --- a/homeassistant/components/srp_energy/translations/de.json +++ b/homeassistant/components/srp_energy/translations/de.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_account": "Die Konto-ID sollte eine 9-stellige Nummer sein", - "invalid_auth": "Ung\u00fcltige Anmeldung", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/switcher_kis/translations/de.json b/homeassistant/components/switcher_kis/translations/de.json new file mode 100644 index 00000000000..19cd4b8c70e --- /dev/null +++ b/homeassistant/components/switcher_kis/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 mit der Einrichtung beginnen?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/de.json b/homeassistant/components/syncthru/translations/de.json index f7533630216..699b88286dc 100644 --- a/homeassistant/components/syncthru/translations/de.json +++ b/homeassistant/components/syncthru/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist schon konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "invalid_url": "Ung\u00fcltige URL", diff --git a/homeassistant/components/synology_dsm/translations/ca.json b/homeassistant/components/synology_dsm/translations/ca.json index e08ed1d74ce..2ac5d16b286 100644 --- a/homeassistant/components/synology_dsm/translations/ca.json +++ b/homeassistant/components/synology_dsm/translations/ca.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -29,6 +30,14 @@ "description": "Vols configurar {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Motiu: {details}", + "title": "Reautenticaci\u00f3 de la integraci\u00f3 Synology DSM" + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index 5a6c52872db..5d769dbe7d6 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -25,19 +24,11 @@ "port": "Port", "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "SSL Zertifikat verifizieren" + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, "description": "M\u00f6chtest du {name} ({host}) einrichten?", "title": "Synology DSM" }, - "reauth": { - "data": { - "password": "Passwort", - "username": "Benutzername" - }, - "description": "Ursache: {details}", - "title": "Synology DSM erneute Authentifizierung notwendig" - }, "user": { "data": { "host": "Host", @@ -45,7 +36,7 @@ "port": "Port", "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "SSL Zertifikat verifizieren" + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, "title": "Synology DSM" } diff --git a/homeassistant/components/synology_dsm/translations/et.json b/homeassistant/components/synology_dsm/translations/et.json index 7d192828d67..eebfd25938b 100644 --- a/homeassistant/components/synology_dsm/translations/et.json +++ b/homeassistant/components/synology_dsm/translations/et.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus", @@ -29,6 +30,14 @@ "description": "Kas soovid seadistada {name}({host})?", "title": "" }, + "reauth": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "P\u00f5hjus: {details}", + "title": "Synology DSM: Taastuvasta sidumine" + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/synology_dsm/translations/ru.json b/homeassistant/components/synology_dsm/translations/ru.json index 9a7157b6dc3..4a2963dc5d5 100644 --- a/homeassistant/components/synology_dsm/translations/ru.json +++ b/homeassistant/components/synology_dsm/translations/ru.json @@ -1,7 +1,8 @@ { "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_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.", @@ -29,6 +30,14 @@ "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u041f\u0440\u0438\u0447\u0438\u043d\u0430: {details}", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f Synology DSM" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/tellduslive/translations/de.json b/homeassistant/components/tellduslive/translations/de.json index 0a952ba013b..adb5f0e2542 100644 --- a/homeassistant/components/tellduslive/translations/de.json +++ b/homeassistant/components/tellduslive/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dienst ist bereits konfiguriert", + "already_configured": "Der Dienst ist bereits konfiguriert", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", "unknown": "Unerwarteter Fehler", "unknown_authorize_url_generation": "Beim Generieren einer Authentifizierungs-URL ist ein unbekannter Fehler aufgetreten" diff --git a/homeassistant/components/tesla/translations/de.json b/homeassistant/components/tesla/translations/de.json index 2fd964fe013..bdcd8237b3b 100644 --- a/homeassistant/components/tesla/translations/de.json +++ b/homeassistant/components/tesla/translations/de.json @@ -13,7 +13,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "description": "Bitte gib deine Daten ein.", "title": "Tesla - Konfiguration" diff --git a/homeassistant/components/tibber/translations/de.json b/homeassistant/components/tibber/translations/de.json index d6339bbf20b..f3f722ae835 100644 --- a/homeassistant/components/tibber/translations/de.json +++ b/homeassistant/components/tibber/translations/de.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "access_token": "Zugriffs-Token" + "access_token": "Zugangstoken" }, "description": "Gib dein Zugangsk\u00fcrzel von https://developer.tibber.com/settings/accesstoken ein.", "title": "Tibber" diff --git a/homeassistant/components/tile/translations/de.json b/homeassistant/components/tile/translations/de.json index 1c2af82aa63..5866a1e0f5b 100644 --- a/homeassistant/components/tile/translations/de.json +++ b/homeassistant/components/tile/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung" @@ -10,7 +10,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail Adresse" + "username": "E-Mail" }, "title": "Kachel konfigurieren" } diff --git a/homeassistant/components/timer/translations/de.json b/homeassistant/components/timer/translations/de.json index 47cf5b15f23..ba24845aadb 100644 --- a/homeassistant/components/timer/translations/de.json +++ b/homeassistant/components/timer/translations/de.json @@ -2,7 +2,7 @@ "state": { "_": { "active": "Aktiv", - "idle": "Leerlauf", + "idle": "Unt\u00e4tig", "paused": "Pausiert" } } diff --git a/homeassistant/components/tplink/translations/de.json b/homeassistant/components/tplink/translations/de.json index 48571158085..6f804a6eeef 100644 --- a/homeassistant/components/tplink/translations/de.json +++ b/homeassistant/components/tplink/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine TP-Link-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/tradfri/translations/de.json b/homeassistant/components/tradfri/translations/de.json index b1ebb2aff0b..ee72be60028 100644 --- a/homeassistant/components/tradfri/translations/de.json +++ b/homeassistant/components/tradfri/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Bridge ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt" }, "error": { diff --git a/homeassistant/components/unifi/translations/de.json b/homeassistant/components/unifi/translations/de.json index bed0cc289ab..ab7f9bb9b16 100644 --- a/homeassistant/components/unifi/translations/de.json +++ b/homeassistant/components/unifi/translations/de.json @@ -57,7 +57,7 @@ "simple_options": { "data": { "block_client": "Clients mit Netzwerkzugriffskontrolle", - "track_clients": "Netzwerger\u00e4te \u00fcberwachen", + "track_clients": "Nachverfolgen von Netzwerkclients", "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)" }, "description": "Konfiguriere die UniFi-Integration" diff --git a/homeassistant/components/upnp/translations/de.json b/homeassistant/components/upnp/translations/de.json index 51be5a6b506..b63d17947ae 100644 --- a/homeassistant/components/upnp/translations/de.json +++ b/homeassistant/components/upnp/translations/de.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "UPnP/IGD ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "incomplete_discovery": "Unvollst\u00e4ndige Suche", - "no_devices_found": "Keine UPnP/IGD-Ger\u00e4te im Netzwerk gefunden." + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" }, "error": { "one": "Ein", diff --git a/homeassistant/components/vesync/translations/de.json b/homeassistant/components/vesync/translations/de.json index ea05a60ff82..bd1ba32fb4a 100644 --- a/homeassistant/components/vesync/translations/de.json +++ b/homeassistant/components/vesync/translations/de.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "title": "Benutzername und Passwort eingeben" } diff --git a/homeassistant/components/vilfo/translations/de.json b/homeassistant/components/vilfo/translations/de.json index 0e146a4159f..798410c56e3 100644 --- a/homeassistant/components/vilfo/translations/de.json +++ b/homeassistant/components/vilfo/translations/de.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Dieser Vilfo Router ist bereits konfiguriert." + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung. Bitte \u00fcberpr\u00fcfe den Zugriffstoken und versuche es erneut.", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, "step": { "user": { "data": { - "access_token": "Zugriffstoken", + "access_token": "Zugangstoken", "host": "Host" }, "description": "Richte die Vilfo Router-Integration ein. Du ben\u00f6tigst deinen Vilfo Router-Hostnamen / deine IP-Adresse und ein API-Zugriffstoken. Weitere Informationen zu dieser Integration und wie du diese Details erh\u00e4ltst, findest du unter: https://www.home-assistant.io/integrations/vilfo", diff --git a/homeassistant/components/wemo/translations/de.json b/homeassistant/components/wemo/translations/de.json index b0735db1249..debbb129459 100644 --- a/homeassistant/components/wemo/translations/de.json +++ b/homeassistant/components/wemo/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine Wemo-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/wiffi/translations/de.json b/homeassistant/components/wiffi/translations/de.json index 4084cda8f9f..c94122cac5e 100644 --- a/homeassistant/components/wiffi/translations/de.json +++ b/homeassistant/components/wiffi/translations/de.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "port": "Server Port" + "port": "Port" }, "title": "TCP-Server f\u00fcr WIFFI-Ger\u00e4te einrichten" } diff --git a/homeassistant/components/wled/translations/de.json b/homeassistant/components/wled/translations/de.json index e97fb86d3e8..01b0839ba32 100644 --- a/homeassistant/components/wled/translations/de.json +++ b/homeassistant/components/wled/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses WLED-Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/xbox/translations/de.json b/homeassistant/components/xbox/translations/de.json index 04f32e05f8b..615c8f8cf2a 100644 --- a/homeassistant/components/xbox/translations/de.json +++ b/homeassistant/components/xbox/translations/de.json @@ -10,7 +10,7 @@ }, "step": { "pick_implementation": { - "title": "Authentifizierungsmethode w\u00e4hlen" + "title": "W\u00e4hle die Authentifizierungsmethode" } } } diff --git a/homeassistant/components/xiaomi_aqara/translations/de.json b/homeassistant/components/xiaomi_aqara/translations/de.json index 87120f09605..bc87f461c33 100644 --- a/homeassistant/components/xiaomi_aqara/translations/de.json +++ b/homeassistant/components/xiaomi_aqara/translations/de.json @@ -31,7 +31,7 @@ }, "user": { "data": { - "host": "IP-Adresse", + "host": "IP-Adresse (optional)", "interface": "Die zu verwendende Netzwerkschnittstelle", "mac": "MAC-Adresse" }, diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index 01a70fe88d6..23a003daa39 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -50,7 +50,7 @@ "name": "Name des Gateways", "token": "API-Token" }, - "description": "Du ben\u00f6tigst den 32 Zeichen langen API-Token. Anweisungen findest du unter https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token.", + "description": "Du ben\u00f6tigst den 32 Zeichen langen API-Token. Anweisungen findest du unter https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token. Bitte beachte, dass sich dieser API-Token von dem Schl\u00fcssel unterscheidet, der von der Xiaomi Aqara Integration verwendet wird.", "title": "Stelle eine Verbindung zu einem Xiaomi Gateway her" }, "manual": { diff --git a/homeassistant/components/zoneminder/translations/de.json b/homeassistant/components/zoneminder/translations/de.json index af053e59ec3..a0bf38b8def 100644 --- a/homeassistant/components/zoneminder/translations/de.json +++ b/homeassistant/components/zoneminder/translations/de.json @@ -23,7 +23,7 @@ "password": "Passwort", "path": "ZM-Pfad", "path_zms": "ZMS-Pfad", - "ssl": "Nutzt ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, diff --git a/homeassistant/components/zwave/translations/de.json b/homeassistant/components/zwave/translations/de.json index 0a82d5b0bc7..b226a2e51e0 100644 --- a/homeassistant/components/zwave/translations/de.json +++ b/homeassistant/components/zwave/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Z-Wave ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { @@ -11,7 +11,7 @@ "user": { "data": { "network_key": "Netzwerkschl\u00fcssel (leer lassen, um automatisch zu generieren)", - "usb_path": "USB-Ger\u00e4t Pfad" + "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" } @@ -26,7 +26,7 @@ }, "query_stage": { "dead": "Nicht erreichbar ({query_stage})", - "initializing": "Initialisiere" + "initializing": "Initialisierend" } } } \ 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 e33ab1f3f2d..6435453b1df 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -56,6 +56,14 @@ "config_parameter": "Wert des Konfigurationsparameters {subtype}", "node_status": "Status des Knotens", "value": "Aktueller Wert eines Z-Wave-Wertes" + }, + "trigger_type": { + "event.notification.entry_control": "Benachrichtigung zur Zugangskontrolle gesendet", + "event.notification.notification": "Benachrichtigung gesendet", + "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" } }, "options": { From 596179d1800ddc11b108602d5c073af850744d36 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Jul 2021 17:12:14 -0700 Subject: [PATCH 079/112] Avoid dataclass incompat with mock spec (#53298) --- tests/components/forecast_solar/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 88f3bf9d4a4..8b9227a8d04 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -58,7 +58,7 @@ def mock_forecast_solar() -> Generator[None, MagicMock, None]: forecast_solar = forecast_solar_mock.return_value now = datetime(2021, 6, 27, 6, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE) - estimate = MagicMock(spec_set=models.Estimate) + estimate = MagicMock(spec=models.Estimate) estimate.now.return_value = now estimate.timezone = "Europe/Amsterdam" estimate.energy_production_today = 100000 From e78a62c8022623c1df8567c49344920374dc4b58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jul 2021 19:22:06 -1000 Subject: [PATCH 080/112] Fix homekit locks not being created from when setup from the UI (#53301) --- homeassistant/components/homekit/config_flow.py | 8 +++++++- homeassistant/components/homekit/strings.json | 2 +- homeassistant/components/homekit/translations/en.json | 2 +- homeassistant/components/homekit/util.py | 5 ++--- tests/components/homekit/test_config_flow.py | 8 +++++--- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 459bb050f65..1ec53079179 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries 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 from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT @@ -58,7 +59,12 @@ MODE_EXCLUDE = "exclude" INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE] -DOMAINS_NEED_ACCESSORY_MODE = [CAMERA_DOMAIN, MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] +DOMAINS_NEED_ACCESSORY_MODE = [ + CAMERA_DOMAIN, + LOCK_DOMAIN, + MEDIA_PLAYER_DOMAIN, + REMOTE_DOMAIN, +] NEVER_BRIDGED_DOMAINS = [CAMERA_DOMAIN] CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 56bc5438eac..3c9671c93e2 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -43,7 +43,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", "title": "Select domains to be included" }, "pairing": { diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index aa78c3e4adc..cee1e64ad56 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", "title": "Select domains to be included" } } diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 673abc5da67..6585e9e9c4e 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -499,12 +499,11 @@ def accessory_friendly_name(hass_name, accessory): def state_needs_accessory_mode(state): """Return if the entity represented by the state must be paired in accessory mode.""" - if state.domain == CAMERA_DOMAIN: + if state.domain in (CAMERA_DOMAIN, LOCK_DOMAIN): return True return ( - state.domain == LOCK_DOMAIN - or state.domain == MEDIA_PLAYER_DOMAIN + state.domain == MEDIA_PLAYER_DOMAIN and state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TV or state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & SUPPORT_ACTIVITY diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index c06e8aaa5ad..f3707f9f71e 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -144,6 +144,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): """Test we can setup a new instance and we create entries for accessory mode devices.""" hass.states.async_set("camera.one", "on") hass.states.async_set("camera.existing", "on") + hass.states.async_set("lock.new", "on") hass.states.async_set("media_player.two", "on", {"device_class": "tv"}) hass.states.async_set("remote.standard", "on") hass.states.async_set("remote.activity", "on", {"supported_features": 4}) @@ -180,7 +181,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"include_domains": ["camera", "media_player", "light", "remote"]}, + {"include_domains": ["camera", "media_player", "light", "lock", "remote"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "pairing" @@ -207,7 +208,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): "filter": { "exclude_domains": [], "exclude_entities": [], - "include_domains": ["media_player", "light", "remote"], + "include_domains": ["media_player", "light", "lock", "remote"], "include_entities": [], }, "exclude_accessory_mode": True, @@ -225,7 +226,8 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): # 4 - camera.one in accessory mode # 5 - media_player.two in accessory mode # 6 - remote.activity in accessory mode - assert len(mock_setup_entry.mock_calls) == 6 + # 7 - lock.new in accessory mode + assert len(mock_setup_entry.mock_calls) == 7 async def test_import(hass): From d98e580c3c4e30fe07e16b763ed2256dc0e8dc16 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 07:24:07 +0200 Subject: [PATCH 081/112] Use NamedTuple - nws (#53293) --- homeassistant/components/nws/const.py | 176 +++++++++++++------------ homeassistant/components/nws/sensor.py | 69 +++------- tests/components/nws/test_sensor.py | 23 ++-- 3 files changed, 122 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index f82a70ea4e0..b5814613847 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -1,5 +1,8 @@ """Constants for National Weather Service Integration.""" +from __future__ import annotations + from datetime import timedelta +from typing import NamedTuple from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -17,7 +20,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY_VARIANT, ) from homeassistant.const import ( - ATTR_DEVICE_CLASS, DEGREE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -40,11 +42,6 @@ ATTRIBUTION = "Data from National Weather Service/NOAA" ATTR_FORECAST_DETAILED_DESCRIPTION = "detailed_description" ATTR_FORECAST_DAYTIME = "daytime" -ATTR_ICON = "icon" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" -ATTR_UNIT_CONVERT = "unit_convert" -ATTR_UNIT_CONVERT_METHOD = "unit_convert_method" CONDITION_CLASSES = { ATTR_CONDITION_EXCEPTIONAL: [ @@ -101,82 +98,93 @@ COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) -SENSOR_TYPES = { - "dewpoint": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Dew Point", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "temperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "windChill": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Wind Chill", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "heatIndex": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Heat Index", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "relativeHumidity": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_LABEL: "Relative Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_UNIT_CONVERT: PERCENTAGE, - }, - "windSpeed": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Speed", - ATTR_UNIT: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_CONVERT: SPEED_MILES_PER_HOUR, - }, - "windGust": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust", - ATTR_UNIT: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_CONVERT: SPEED_MILES_PER_HOUR, - }, - "windDirection": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:compass-rose", - ATTR_LABEL: "Wind Direction", - ATTR_UNIT: DEGREE, - ATTR_UNIT_CONVERT: DEGREE, - }, - "barometricPressure": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_ICON: None, - ATTR_LABEL: "Barometric Pressure", - ATTR_UNIT: PRESSURE_PA, - ATTR_UNIT_CONVERT: PRESSURE_INHG, - }, - "seaLevelPressure": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_ICON: None, - ATTR_LABEL: "Sea Level Pressure", - ATTR_UNIT: PRESSURE_PA, - ATTR_UNIT_CONVERT: PRESSURE_INHG, - }, - "visibility": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:eye", - ATTR_LABEL: "Visibility", - ATTR_UNIT: LENGTH_METERS, - ATTR_UNIT_CONVERT: LENGTH_MILES, - }, + +class NWSSensorMetadata(NamedTuple): + """Sensor metadata for an individual NWS sensor.""" + + label: str + icon: str | None + device_class: str | None + unit: str + unit_convert: str + + +SENSOR_TYPES: dict[str, NWSSensorMetadata] = { + "dewpoint": NWSSensorMetadata( + "Dew Point", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + "temperature": NWSSensorMetadata( + "Temperature", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + "windChill": NWSSensorMetadata( + "Wind Chill", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + "heatIndex": NWSSensorMetadata( + "Heat Index", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + "relativeHumidity": NWSSensorMetadata( + "Relative Humidity", + icon=None, + device_class=DEVICE_CLASS_HUMIDITY, + unit=PERCENTAGE, + unit_convert=PERCENTAGE, + ), + "windSpeed": NWSSensorMetadata( + "Wind Speed", + icon="mdi:weather-windy", + device_class=None, + unit=SPEED_KILOMETERS_PER_HOUR, + unit_convert=SPEED_MILES_PER_HOUR, + ), + "windGust": NWSSensorMetadata( + "Wind Gust", + icon="mdi:weather-windy", + device_class=None, + unit=SPEED_KILOMETERS_PER_HOUR, + unit_convert=SPEED_MILES_PER_HOUR, + ), + "windDirection": NWSSensorMetadata( + "Wind Direction", + icon="mdi:compass-rose", + device_class=None, + unit=DEGREE, + unit_convert=DEGREE, + ), + "barometricPressure": NWSSensorMetadata( + "Barometric Pressure", + icon=None, + device_class=DEVICE_CLASS_PRESSURE, + unit=PRESSURE_PA, + unit_convert=PRESSURE_INHG, + ), + "seaLevelPressure": NWSSensorMetadata( + "Sea Level Pressure", + icon=None, + device_class=DEVICE_CLASS_PRESSURE, + unit=PRESSURE_PA, + unit_convert=PRESSURE_INHG, + ), + "visibility": NWSSensorMetadata( + "Visibility", + icon="mdi:eye", + device_class=None, + unit=LENGTH_METERS, + unit_convert=LENGTH_MILES, + ), } diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index bff5cdca589..8bbf6af8057 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -2,7 +2,6 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, CONF_LATITUDE, CONF_LONGITUDE, LENGTH_KILOMETERS, @@ -14,6 +13,7 @@ from homeassistant.const import ( SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow @@ -21,10 +21,6 @@ from homeassistant.util.pressure import convert as convert_pressure from . import base_unique_id from .const import ( - ATTR_ICON, - ATTR_LABEL, - ATTR_UNIT, - ATTR_UNIT_CONVERT, ATTRIBUTION, CONF_STATION, COORDINATOR_OBSERVATION, @@ -32,6 +28,7 @@ from .const import ( NWS_DATA, OBSERVATION_VALID_TIME, SENSOR_TYPES, + NWSSensorMetadata, ) PARALLEL_UPDATES = 0 @@ -43,21 +40,15 @@ async def async_setup_entry(hass, entry, async_add_entities): station = entry.data[CONF_STATION] entities = [] - for sensor_type, sensor_data in SENSOR_TYPES.items(): - if hass.config.units.is_metric: - unit = sensor_data[ATTR_UNIT] - else: - unit = sensor_data[ATTR_UNIT_CONVERT] + for sensor_type, metadata in SENSOR_TYPES.items(): entities.append( NWSSensor( + hass, entry.data, hass_data, sensor_type, + metadata, station, - sensor_data[ATTR_LABEL], - sensor_data[ATTR_ICON], - sensor_data[ATTR_DEVICE_CLASS], - unit, ), ) @@ -69,14 +60,12 @@ class NWSSensor(CoordinatorEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, entry_data, hass_data, sensor_type, + metadata: NWSSensorMetadata, station, - label, - icon, - device_class, - unit, ): """Initialise the platform with a data instance.""" super().__init__(hass_data[COORDINATOR_OBSERVATION]) @@ -84,11 +73,15 @@ class NWSSensor(CoordinatorEntity, SensorEntity): self._latitude = entry_data[CONF_LATITUDE] self._longitude = entry_data[CONF_LONGITUDE] self._type = sensor_type - self._station = station - self._label = label - self._icon = icon - self._device_class = device_class - self._unit = unit + self._metadata = metadata + + self._attr_name = f"{station} {metadata.label}" + self._attr_icon = metadata.icon + self._attr_device_class = metadata.device_class + if hass.config.units.is_metric: + self._attr_unit_of_measurement = metadata.unit + else: + self._attr_unit_of_measurement = metadata.unit_convert @property def state(self): @@ -96,43 +89,23 @@ class NWSSensor(CoordinatorEntity, SensorEntity): value = self._nws.observation.get(self._type) if value is None: return None - if self._unit == SPEED_MILES_PER_HOUR: + if self._attr_unit_of_measurement == SPEED_MILES_PER_HOUR: return round(convert_distance(value, LENGTH_KILOMETERS, LENGTH_MILES)) - if self._unit == LENGTH_MILES: + if self._attr_unit_of_measurement == LENGTH_MILES: return round(convert_distance(value, LENGTH_METERS, LENGTH_MILES)) - if self._unit == PRESSURE_INHG: + if self._attr_unit_of_measurement == PRESSURE_INHG: return round(convert_pressure(value, PRESSURE_PA, PRESSURE_INHG), 2) - if self._unit == TEMP_CELSIUS: + if self._attr_unit_of_measurement == TEMP_CELSIUS: return round(value, 1) - if self._unit == PERCENTAGE: + if self._attr_unit_of_measurement == PERCENTAGE: return round(value) return value - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - @property def device_state_attributes(self): """Return the attribution.""" return {ATTR_ATTRIBUTION: ATTRIBUTION} - @property - def name(self): - """Return the name of the station.""" - return f"{self._station} {self._label}" - @property def unique_id(self): """Return a unique_id for this entity.""" diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py index 44b181b1ec4..f5c0773380d 100644 --- a/tests/components/nws/test_sensor.py +++ b/tests/components/nws/test_sensor.py @@ -1,12 +1,7 @@ """Sensors for National Weather Service (NWS).""" import pytest -from homeassistant.components.nws.const import ( - ATTR_LABEL, - ATTRIBUTION, - DOMAIN, - SENSOR_TYPES, -) +from homeassistant.components.nws.const import ATTRIBUTION, DOMAIN, SENSOR_TYPES from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN from homeassistant.util import slugify @@ -40,12 +35,12 @@ async def test_imperial_metric( """Test with imperial and metric units.""" registry = await hass.helpers.entity_registry.async_get_registry() - for sensor_name, sensor_data in SENSOR_TYPES.items(): + for sensor_name, metadata in SENSOR_TYPES.items(): registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, f"35_-75_{sensor_name}", - suggested_object_id=f"abc_{sensor_data[ATTR_LABEL]}", + suggested_object_id=f"abc_{metadata.label}", disabled_by=None, ) @@ -58,8 +53,8 @@ async def test_imperial_metric( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - for sensor_name, sensor_data in SENSOR_TYPES.items(): - state = hass.states.get(f"sensor.abc_{slugify(sensor_data[ATTR_LABEL])}") + for sensor_name, metadata in SENSOR_TYPES.items(): + state = hass.states.get(f"sensor.abc_{slugify(metadata.label)}") assert state assert state.state == result_observation[sensor_name] assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -72,12 +67,12 @@ async def test_none_values(hass, mock_simple_nws, no_weather): registry = await hass.helpers.entity_registry.async_get_registry() - for sensor_name, sensor_data in SENSOR_TYPES.items(): + for sensor_name, metadata in SENSOR_TYPES.items(): registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, f"35_-75_{sensor_name}", - suggested_object_id=f"abc_{sensor_data[ATTR_LABEL]}", + suggested_object_id=f"abc_{metadata.label}", disabled_by=None, ) @@ -89,7 +84,7 @@ async def test_none_values(hass, mock_simple_nws, no_weather): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - for sensor_name, sensor_data in SENSOR_TYPES.items(): - state = hass.states.get(f"sensor.abc_{slugify(sensor_data[ATTR_LABEL])}") + for sensor_name, metadata in SENSOR_TYPES.items(): + state = hass.states.get(f"sensor.abc_{slugify(metadata.label)}") assert state assert state.state == STATE_UNKNOWN From f5480481cd29e7dd1db71a6be596abaa70288409 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 07:25:38 +0200 Subject: [PATCH 082/112] Use NamedTuple - metoffice (#53294) --- homeassistant/components/metoffice/sensor.py | 195 ++++++++++++------- 1 file changed, 122 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 6b45dac22e7..1307c3aae45 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -1,4 +1,8 @@ """Support for UK Met Office weather service.""" +from __future__ import annotations + +from typing import NamedTuple + from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -34,51 +38,102 @@ ATTR_SENSOR_ID = "sensor_id" ATTR_SITE_ID = "site_id" ATTR_SITE_NAME = "site_name" -# Sensor types are defined as: -# variable -> [0]title, [1]device_class, [2]units, [3]icon, [4]enabled_by_default + +class MetOfficeSensorMetadata(NamedTuple): + """Sensor metadata for an individual NWS sensor.""" + + title: str + device_class: str | None + unit_of_measurement: str | None + icon: str | None + enabled_by_default: bool + + SENSOR_TYPES = { - "name": ["Station Name", None, None, "mdi:label-outline", False], - "weather": [ + "name": MetOfficeSensorMetadata( + "Station Name", + device_class=None, + unit_of_measurement=None, + icon="mdi:label-outline", + enabled_by_default=False, + ), + "weather": MetOfficeSensorMetadata( "Weather", - None, - None, - "mdi:weather-sunny", # but will adapt to current conditions - True, - ], - "temperature": ["Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None, True], - "feels_like_temperature": [ + device_class=None, + unit_of_measurement=None, + icon="mdi:weather-sunny", # but will adapt to current conditions + enabled_by_default=True, + ), + "temperature": MetOfficeSensorMetadata( + "Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None, + enabled_by_default=True, + ), + "feels_like_temperature": MetOfficeSensorMetadata( "Feels Like Temperature", - DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, - None, - False, - ], - "wind_speed": [ + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None, + enabled_by_default=False, + ), + "wind_speed": MetOfficeSensorMetadata( "Wind Speed", - None, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - True, - ], - "wind_direction": ["Wind Direction", None, None, "mdi:compass-outline", False], - "wind_gust": ["Wind Gust", None, SPEED_MILES_PER_HOUR, "mdi:weather-windy", False], - "visibility": ["Visibility", None, None, "mdi:eye", False], - "visibility_distance": [ + device_class=None, + unit_of_measurement=SPEED_MILES_PER_HOUR, + icon="mdi:weather-windy", + enabled_by_default=True, + ), + "wind_direction": MetOfficeSensorMetadata( + "Wind Direction", + device_class=None, + unit_of_measurement=None, + icon="mdi:compass-outline", + enabled_by_default=False, + ), + "wind_gust": MetOfficeSensorMetadata( + "Wind Gust", + device_class=None, + unit_of_measurement=SPEED_MILES_PER_HOUR, + icon="mdi:weather-windy", + enabled_by_default=False, + ), + "visibility": MetOfficeSensorMetadata( + "Visibility", + device_class=None, + unit_of_measurement=None, + icon="mdi:eye", + enabled_by_default=False, + ), + "visibility_distance": MetOfficeSensorMetadata( "Visibility Distance", - None, - LENGTH_KILOMETERS, - "mdi:eye", - False, - ], - "uv": ["UV Index", None, UV_INDEX, "mdi:weather-sunny-alert", True], - "precipitation": [ + device_class=None, + unit_of_measurement=LENGTH_KILOMETERS, + icon="mdi:eye", + enabled_by_default=False, + ), + "uv": MetOfficeSensorMetadata( + "UV Index", + device_class=None, + unit_of_measurement=UV_INDEX, + icon="mdi:weather-sunny-alert", + enabled_by_default=True, + ), + "precipitation": MetOfficeSensorMetadata( "Probability of Precipitation", - None, - PERCENTAGE, - "mdi:weather-rainy", - True, - ], - "humidity": ["Humidity", DEVICE_CLASS_HUMIDITY, PERCENTAGE, None, False], + device_class=None, + unit_of_measurement=PERCENTAGE, + icon="mdi:weather-rainy", + enabled_by_default=True, + ), + "humidity": MetOfficeSensorMetadata( + "Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + unit_of_measurement=PERCENTAGE, + icon=None, + enabled_by_default=False, + ), } @@ -91,15 +146,23 @@ async def async_setup_entry( async_add_entities( [ MetOfficeCurrentSensor( - hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, True, sensor_type + hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data, + True, + sensor_type, + metadata, ) - for sensor_type in SENSOR_TYPES + for sensor_type, metadata in SENSOR_TYPES.items() ] + [ MetOfficeCurrentSensor( - hass_data[METOFFICE_DAILY_COORDINATOR], hass_data, False, sensor_type + hass_data[METOFFICE_DAILY_COORDINATOR], + hass_data, + False, + sensor_type, + metadata, ) - for sensor_type in SENSOR_TYPES + for sensor_type, metadata in SENSOR_TYPES.items() ], False, ) @@ -108,33 +171,29 @@ async def async_setup_entry( class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): """Implementation of a Met Office current weather condition sensor.""" - def __init__(self, coordinator, hass_data, use_3hourly, sensor_type): + def __init__( + self, + coordinator, + hass_data, + use_3hourly, + sensor_type, + metadata: MetOfficeSensorMetadata, + ): """Initialize the sensor.""" super().__init__(coordinator) self._type = sensor_type + self._metadata = metadata mode_label = MODE_3HOURLY_LABEL if use_3hourly else MODE_DAILY_LABEL - self._name = ( - f"{hass_data[METOFFICE_NAME]} {SENSOR_TYPES[self._type][0]} {mode_label}" - ) - self._unique_id = ( - f"{SENSOR_TYPES[self._type][0]}_{hass_data[METOFFICE_COORDINATES]}" - ) + self._attr_name = f"{hass_data[METOFFICE_NAME]} {metadata.title} {mode_label}" + self._attr_unique_id = f"{metadata.title}_{hass_data[METOFFICE_COORDINATES]}" if not use_3hourly: - self._unique_id = f"{self._unique_id}_{MODE_DAILY}" + self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}" + self._attr_device_class = metadata.device_class + self._attr_unit_of_measurement = metadata.unit_of_measurement self.use_3hourly = use_3hourly - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique of the sensor.""" - return self._unique_id - @property def state(self): """Return the state of the sensor.""" @@ -167,15 +226,10 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): return value - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][2] - @property def icon(self): """Return the icon for the entity card.""" - value = SENSOR_TYPES[self._type][3] + value = self._metadata.icon if self._type == "weather": value = self.state if value is None: @@ -186,11 +240,6 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): return value - @property - def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self._type][1] - @property def extra_state_attributes(self): """Return the state attributes of the device.""" @@ -205,4 +254,4 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" - return SENSOR_TYPES[self._type][4] and self.use_3hourly + return self._metadata.enabled_by_default and self.use_3hourly From 5c3fb77660b76285fa379becf4e894664a9bb6f5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 07:27:01 +0200 Subject: [PATCH 083/112] Use NamedTuple - glances (#53297) --- homeassistant/components/glances/const.py | 194 ++++++++++++++++----- homeassistant/components/glances/sensor.py | 57 ++---- 2 files changed, 165 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 8e20fbfa46b..56d55931cdb 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,5 +1,8 @@ """Constants for Glances component.""" +from __future__ import annotations + import sys +from typing import NamedTuple from homeassistant.const import ( DATA_GIBIBYTES, @@ -26,51 +29,148 @@ if sys.maxsize > 2 ** 32: else: CPU_ICON = "mdi:cpu-32-bit" -SENSOR_TYPES = { - "disk_use_percent": ["fs", "used percent", PERCENTAGE, "mdi:harddisk", None], - "disk_use": ["fs", "used", DATA_GIBIBYTES, "mdi:harddisk", None], - "disk_free": ["fs", "free", DATA_GIBIBYTES, "mdi:harddisk", None], - "memory_use_percent": ["mem", "RAM used percent", PERCENTAGE, "mdi:memory", None], - "memory_use": ["mem", "RAM used", DATA_MEBIBYTES, "mdi:memory", None], - "memory_free": ["mem", "RAM free", DATA_MEBIBYTES, "mdi:memory", None], - "swap_use_percent": [ - "memswap", - "Swap used percent", - PERCENTAGE, - "mdi:memory", - None, - ], - "swap_use": ["memswap", "Swap used", DATA_GIBIBYTES, "mdi:memory", None], - "swap_free": ["memswap", "Swap free", DATA_GIBIBYTES, "mdi:memory", None], - "processor_load": ["load", "CPU load", "15 min", CPU_ICON, None], - "process_running": ["processcount", "Running", "Count", CPU_ICON, None], - "process_total": ["processcount", "Total", "Count", CPU_ICON, None], - "process_thread": ["processcount", "Thread", "Count", CPU_ICON, None], - "process_sleeping": ["processcount", "Sleeping", "Count", CPU_ICON, None], - "cpu_use_percent": ["cpu", "CPU used", PERCENTAGE, CPU_ICON, None], - "temperature_core": [ - "sensors", - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_hdd": [ - "sensors", - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "fan_speed": ["sensors", "Fan speed", "RPM", "mdi:fan", None], - "battery": ["sensors", "Charge", PERCENTAGE, "mdi:battery", None], - "docker_active": ["docker", "Containers active", "", "mdi:docker", None], - "docker_cpu_use": ["docker", "Containers CPU used", PERCENTAGE, "mdi:docker", None], - "docker_memory_use": [ - "docker", - "Containers RAM used", - DATA_MEBIBYTES, - "mdi:docker", - None, - ], + +class GlancesSensorMetadata(NamedTuple): + """Sensor metadata for an individual Glances sensor.""" + + type: str + name_suffix: str + unit_of_measurement: str + icon: str | None = None + device_class: str | None = None + + +SENSOR_TYPES: dict[str, GlancesSensorMetadata] = { + "disk_use_percent": GlancesSensorMetadata( + type="fs", + name_suffix="used percent", + unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + ), + "disk_use": GlancesSensorMetadata( + type="fs", + name_suffix="used", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + ), + "disk_free": GlancesSensorMetadata( + type="fs", + name_suffix="free", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + ), + "memory_use_percent": GlancesSensorMetadata( + type="mem", + name_suffix="RAM used percent", + unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), + "memory_use": GlancesSensorMetadata( + type="mem", + name_suffix="RAM used", + unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + ), + "memory_free": GlancesSensorMetadata( + type="mem", + name_suffix="RAM free", + unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + ), + "swap_use_percent": GlancesSensorMetadata( + type="memswap", + name_suffix="Swap used percent", + unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), + "swap_use": GlancesSensorMetadata( + type="memswap", + name_suffix="Swap used", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + "swap_free": GlancesSensorMetadata( + type="memswap", + name_suffix="Swap free", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + "processor_load": GlancesSensorMetadata( + type="load", + name_suffix="CPU load", + unit_of_measurement="15 min", + icon=CPU_ICON, + ), + "process_running": GlancesSensorMetadata( + type="processcount", + name_suffix="Running", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + "process_total": GlancesSensorMetadata( + type="processcount", + name_suffix="Total", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + "process_thread": GlancesSensorMetadata( + type="processcount", + name_suffix="Thread", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + "process_sleeping": GlancesSensorMetadata( + type="processcount", + name_suffix="Sleeping", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + "cpu_use_percent": GlancesSensorMetadata( + type="cpu", + name_suffix="CPU used", + unit_of_measurement=PERCENTAGE, + icon=CPU_ICON, + ), + "temperature_core": GlancesSensorMetadata( + type="sensors", + name_suffix="Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "temperature_hdd": GlancesSensorMetadata( + type="sensors", + name_suffix="Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "fan_speed": GlancesSensorMetadata( + type="sensors", + name_suffix="Fan speed", + unit_of_measurement="RPM", + icon="mdi:fan", + ), + "battery": GlancesSensorMetadata( + type="sensors", + name_suffix="Charge", + unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + "docker_active": GlancesSensorMetadata( + type="docker", + name_suffix="Containers active", + unit_of_measurement="", + icon="mdi:docker", + ), + "docker_cpu_use": GlancesSensorMetadata( + type="docker", + name_suffix="Containers CPU used", + unit_of_measurement=PERCENTAGE, + icon="mdi:docker", + ), + "docker_memory_use": GlancesSensorMetadata( + type="docker", + name_suffix="Containers RAM used", + unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:docker", + ), } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 0e032de67be..e33fd121200 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -4,7 +4,7 @@ from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES +from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES, GlancesSensorMetadata async def async_setup_entry(hass, config_entry, async_add_entities): @@ -14,45 +14,42 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = config_entry.data[CONF_NAME] dev = [] - for sensor_type, sensor_details in SENSOR_TYPES.items(): - if sensor_details[0] not in client.api.data: + for sensor_type, metadata in SENSOR_TYPES.items(): + if metadata.type not in client.api.data: continue - if sensor_details[0] == "fs": + if metadata.type == "fs": # fs will provide a list of disks attached - for disk in client.api.data[sensor_details[0]]: + for disk in client.api.data[metadata.type]: dev.append( GlancesSensor( client, name, disk["mnt_point"], - sensor_details[1], sensor_type, - sensor_details, + metadata, ) ) - elif sensor_details[0] == "sensors": + elif metadata.type == "sensors": # sensors will provide temp for different devices - for sensor in client.api.data[sensor_details[0]]: + for sensor in client.api.data[metadata.type]: if sensor["type"] == sensor_type: dev.append( GlancesSensor( client, name, sensor["label"], - sensor_details[1], sensor_type, - sensor_details, + metadata, ) ) - elif client.api.data[sensor_details[0]]: + elif client.api.data[metadata.type]: dev.append( GlancesSensor( client, name, "", - sensor_details[1], sensor_type, - sensor_details, + metadata, ) ) @@ -67,45 +64,27 @@ class GlancesSensor(SensorEntity): glances_data, name, sensor_name_prefix, - sensor_name_suffix, sensor_type, - sensor_details, + metadata: GlancesSensorMetadata, ): """Initialize the sensor.""" self.glances_data = glances_data self._sensor_name_prefix = sensor_name_prefix - self._sensor_name_suffix = sensor_name_suffix - self._name = name self.type = sensor_type self._state = None - self.sensor_details = sensor_details + self._metadata = metadata self.unsub_update = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._sensor_name_prefix} {self._sensor_name_suffix}" + self._attr_name = f"{name} {sensor_name_prefix} {metadata.name_suffix}" + self._attr_icon = metadata.icon + self._attr_unit_of_measurement = metadata.unit_of_measurement + self._attr_device_class = metadata.device_class @property def unique_id(self): """Set unique_id for sensor.""" return f"{self.glances_data.host}-{self.name}" - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self.sensor_details[4] - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self.sensor_details[3] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.sensor_details[2] - @property def available(self): """Could the device be accessed during the last update call.""" @@ -143,7 +122,7 @@ class GlancesSensor(SensorEntity): if value is None: return - if self.sensor_details[0] == "fs": + if self._metadata.type == "fs": for var in value["fs"]: if var["mnt_point"] == self._sensor_name_prefix: disk = var From 551c1177172850a9fb6afbed3bf99c1740fb470b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 07:27:31 +0200 Subject: [PATCH 084/112] Use NamedTuple - ondilo_ico (#53296) --- homeassistant/components/ondilo_ico/sensor.py | 102 ++++++++++-------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 633e03157e4..122b4154892 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -1,6 +1,9 @@ """Platform for sensor integration.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import NamedTuple from ondilo import OndiloError @@ -22,29 +25,59 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN -SENSOR_TYPES = { - "temperature": [ + +class OndiloIOCSensorMetadata(NamedTuple): + """Sensor metadata for an individual Ondilo IOC sensor.""" + + name: str + unit_of_measurement: str | None + icon: str | None + device_class: str | None + + +SENSOR_TYPES: dict[str, OndiloIOCSensorMetadata] = { + "temperature": OndiloIOCSensorMetadata( "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "orp": [ + unit_of_measurement=TEMP_CELSIUS, + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "orp": OndiloIOCSensorMetadata( "Oxydo Reduction Potential", - ELECTRIC_POTENTIAL_MILLIVOLT, - "mdi:pool", - None, - ], - "ph": ["pH", "", "mdi:pool", None], - "tds": ["TDS", CONCENTRATION_PARTS_PER_MILLION, "mdi:pool", None], - "battery": ["Battery", PERCENTAGE, None, DEVICE_CLASS_BATTERY], - "rssi": [ + unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + device_class=None, + ), + "ph": OndiloIOCSensorMetadata( + "pH", + unit_of_measurement=None, + icon="mdi:pool", + device_class=None, + ), + "tds": OndiloIOCSensorMetadata( + "TDS", + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:pool", + device_class=None, + ), + "battery": OndiloIOCSensorMetadata( + "Battery", + unit_of_measurement=PERCENTAGE, + icon=None, + device_class=DEVICE_CLASS_BATTERY, + ), + "rssi": OndiloIOCSensorMetadata( "RSSI", - PERCENTAGE, - None, - DEVICE_CLASS_SIGNAL_STRENGTH, - ], - "salt": ["Salt", "mg/L", "mdi:pool", None], + unit_of_measurement=PERCENTAGE, + icon=None, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), + "salt": OndiloIOCSensorMetadata( + "Salt", + unit_of_measurement="mg/L", + icon="mdi:pool", + device_class=None, + ), } SCAN_INTERVAL = timedelta(hours=1) @@ -105,10 +138,11 @@ class OndiloICO(CoordinatorEntity, SensorEntity): self._data_type = pooldata["sensors"][sensor_idx]["data_type"] self._unique_id = f"{pooldata['ICO']['serial_number']}-{self._data_type}" self._device_name = pooldata["name"] - self._name = f"{self._device_name} {SENSOR_TYPES[self._data_type][0]}" - self._device_class = SENSOR_TYPES[self._data_type][3] - self._icon = SENSOR_TYPES[self._data_type][2] - self._unit = SENSOR_TYPES[self._data_type][1] + metadata = SENSOR_TYPES[self._data_type] + self._name = f"{self._device_name} {metadata.name}" + self._attr_device_class = metadata.device_class + self._attr_icon = metadata.icon + self._attr_unit_of_measurement = metadata.unit_of_measurement def _pooldata(self): """Get pool data dict.""" @@ -128,31 +162,11 @@ class OndiloICO(CoordinatorEntity, SensorEntity): None, ) - @property - def name(self): - """Name of the sensor.""" - return self._name - @property def state(self): """Last value of the sensor.""" return self._devdata()["value"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the Unit of the sensor's measurement.""" - return self._unit - @property def unique_id(self): """Return the unique ID of this entity.""" From 560bde94efb43dfbbb8761fc665ebe9bb30edd01 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 07:28:02 +0200 Subject: [PATCH 085/112] Use NamedTuple - epsonworkforce (#53295) --- .../components/epsonworkforce/sensor.py | 72 ++++++++++++------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 22f74e1c0b1..65a5e6342f1 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -1,5 +1,8 @@ """Support for Epson Workforce Printer.""" +from __future__ import annotations + from datetime import timedelta +from typing import NamedTuple from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI import voluptuous as vol @@ -9,13 +12,46 @@ from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS, PERCENTAGE from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -MONITORED_CONDITIONS = { - "black": ["Ink level Black", PERCENTAGE, "mdi:water"], - "photoblack": ["Ink level Photoblack", PERCENTAGE, "mdi:water"], - "magenta": ["Ink level Magenta", PERCENTAGE, "mdi:water"], - "cyan": ["Ink level Cyan", PERCENTAGE, "mdi:water"], - "yellow": ["Ink level Yellow", PERCENTAGE, "mdi:water"], - "clean": ["Cleaning level", PERCENTAGE, "mdi:water"], + +class MonitoredConditionsMetadata(NamedTuple): + """Metadata for an individual montiored condition.""" + + name: str + icon: str + unit_of_measurement: str + + +MONITORED_CONDITIONS: dict[str, MonitoredConditionsMetadata] = { + "black": MonitoredConditionsMetadata( + "Ink level Black", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + "photoblack": MonitoredConditionsMetadata( + "Ink level Photoblack", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + "magenta": MonitoredConditionsMetadata( + "Ink level Magenta", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + "cyan": MonitoredConditionsMetadata( + "Ink level Cyan", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + "yellow": MonitoredConditionsMetadata( + "Ink level Yellow", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + "clean": MonitoredConditionsMetadata( + "Cleaning level", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -52,24 +88,10 @@ class EpsonPrinterCartridge(SensorEntity): self._api = api self._id = cartridgeidx - self._name = MONITORED_CONDITIONS[self._id][0] - self._unit = MONITORED_CONDITIONS[self._id][1] - self._icon = MONITORED_CONDITIONS[self._id][2] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit + metadata = MONITORED_CONDITIONS[self._id] + self._attr_name = metadata.name + self._attr_icon = metadata.icon + self._attr_unit_of_measurement = metadata.unit_of_measurement @property def state(self): From 1bde91407508ca600c5c0f905140f9596731e82a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 22 Jul 2021 00:01:05 -0600 Subject: [PATCH 086/112] Ensure Guardian is strictly typed (#53253) --- .strict-typing | 1 + homeassistant/components/guardian/__init__.py | 51 +++++++++++-------- .../components/guardian/binary_sensor.py | 2 +- .../components/guardian/config_flow.py | 4 +- .../components/guardian/manifest.json | 2 +- homeassistant/components/guardian/sensor.py | 2 +- homeassistant/components/guardian/switch.py | 24 +++++---- homeassistant/components/guardian/util.py | 6 +-- mypy.ini | 14 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/mypy_config.py | 1 - 12 files changed, 67 insertions(+), 44 deletions(-) diff --git a/.strict-typing b/.strict-typing index 98a96f98fb0..19835bfd777 100644 --- a/.strict-typing +++ b/.strict-typing @@ -43,6 +43,7 @@ homeassistant.components.fritz.* homeassistant.components.geo_location.* homeassistant.components.gios.* homeassistant.components.group.* +homeassistant.components.guardian.* homeassistant.components.history.* homeassistant.components.homeassistant.triggers.event homeassistant.components.http.* diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 8e605ca121c..96f5ed36720 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, MutableMapping +from typing import Any, cast from aioguardian import Client @@ -89,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await paired_sensor_manager.async_process_latest_paired_sensor_uids() @callback - def async_process_paired_sensor_uids(): + def async_process_paired_sensor_uids() -> None: """Define a callback for when new paired sensor data is received.""" hass.async_create_task( paired_sensor_manager.async_process_latest_paired_sensor_uids() @@ -133,8 +135,7 @@ class PairedSensorManager: self._client = client self._entry = entry self._hass = hass - self._listeners = [] - self._paired_uids = set() + self._paired_uids: set[str] = set() async def async_pair_sensor(self, uid: str) -> None: """Add a new paired sensor coordinator.""" @@ -148,7 +149,9 @@ class PairedSensorManager: self._hass, client=self._client, api_name=f"{API_SENSOR_PAIRED_SENSOR_STATUS}_{uid}", - api_coro=lambda: self._client.sensor.paired_sensor_status(uid), + api_coro=lambda: cast( + Awaitable, self._client.sensor.paired_sensor_status(uid) + ), api_lock=self._api_lock, valve_controller_uid=self._entry.data[CONF_UID], ) @@ -208,12 +211,19 @@ 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, icon: str + self, + entry: ConfigEntry, + kind: str, + name: str, + device_class: str | None, + icon: str | None, ) -> 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_extra_state_attributes: MutableMapping[str, Any] = { + ATTR_ATTRIBUTION: "Data provided by Elexa" + } self._attr_icon = icon self._attr_name = name self._entry = entry @@ -236,16 +246,18 @@ class PairedSensorEntity(GuardianEntity): coordinator: DataUpdateCoordinator, kind: str, name: str, - device_class: str, - icon: str, + device_class: str | None, + icon: str | None, ) -> None: """Initialize.""" super().__init__(entry, kind, name, device_class, icon) paired_sensor_uid = coordinator.data["uid"] - self._attr_device_info["identifiers"] = {(DOMAIN, paired_sensor_uid)} - self._attr_device_info["name"] = f"Guardian Paired Sensor {paired_sensor_uid}" - self._attr_device_info["via_device"] = (DOMAIN, entry.data[CONF_UID]) + self._attr_device_info = { + "identifiers": {(DOMAIN, paired_sensor_uid)}, + "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 @@ -271,13 +283,11 @@ class ValveControllerEntity(GuardianEntity): """Initialize.""" super().__init__(entry, kind, name, device_class, icon) - self._attr_device_info["identifiers"] = {(DOMAIN, entry.data[CONF_UID])} - self._attr_device_info[ - "name" - ] = f"Guardian Valve Controller {entry.data[CONF_UID]}" - self._attr_device_info["model"] = coordinators[API_SYSTEM_DIAGNOSTICS].data[ - "firmware" - ] + 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 @@ -304,7 +314,7 @@ class ValveControllerEntity(GuardianEntity): """Add a listener to a DataUpdateCoordinator based on the API referenced.""" @callback - def update(): + def update() -> None: """Update the entity's state.""" self._async_update_from_latest_data() self.async_write_ha_state() @@ -327,6 +337,7 @@ class ValveControllerEntity(GuardianEntity): return refresh_tasks = [ - coordinator.async_request_refresh() for coordinator in self.coordinators + coordinator.async_request_refresh() + for coordinator in self.coordinators.values() ] await asyncio.gather(*refresh_tasks) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 7d38a431f4c..8ce381e0456 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -78,7 +78,7 @@ async def async_setup_entry( ) ) - sensors = [] + sensors: list[PairedSensorBinarySensor | ValveControllerBinarySensor] = [] # Add all valve controller-specific binary sensors: for kind in VALVE_CONTROLLER_SENSORS: diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index edbf4ef9c83..ccebeb99675 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -40,7 +40,7 @@ def async_get_pin_from_uid(uid: str) -> str: return uid[-4:] -async def validate_input(hass: HomeAssistant, data: dict[str, Any]): +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 DATA_SCHEMA with values provided by the user. @@ -60,7 +60,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self.discovery_info = {} + self.discovery_info: dict[str, Any] = {} async def _async_set_unique_id(self, pin: str) -> None: """Set the config entry's unique ID (based on the device's 4-digit PIN).""" diff --git a/homeassistant/components/guardian/manifest.json b/homeassistant/components/guardian/manifest.json index 60411c5292b..baa7eb50e7a 100644 --- a/homeassistant/components/guardian/manifest.json +++ b/homeassistant/components/guardian/manifest.json @@ -3,7 +3,7 @@ "name": "Elexa Guardian", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/guardian", - "requirements": ["aioguardian==1.0.4"], + "requirements": ["aioguardian==1.0.8"], "zeroconf": ["_api._udp.local."], "codeowners": ["@bachya"], "iot_class": "local_polling", diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 1aaf83b8fb8..ce09bb99c60 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -78,7 +78,7 @@ async def async_setup_entry( ) ) - sensors = [] + sensors: list[PairedSensorSensor | ValveControllerSensor] = [] # Add all valve controller-specific binary sensors: for kind in VALVE_CONTROLLER_SENSORS: diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index ef74a35147f..f3621a72952 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,6 +1,8 @@ """Switches for the Elexa Guardian integration.""" from __future__ import annotations +from typing import Any + from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol @@ -95,7 +97,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._attr_is_on = True self._client = client - async def _async_continue_entity_setup(self): + async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" self.async_add_coordinator_update_listener(API_VALVE_STATUS) @@ -127,7 +129,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): } ) - async def async_disable_ap(self): + async def async_disable_ap(self) -> None: """Disable the device's onboard access point.""" try: async with self._client: @@ -135,7 +137,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while disabling valve controller AP: %s", err) - async def async_enable_ap(self): + async def async_enable_ap(self) -> None: """Enable the device's onboard access point.""" try: async with self._client: @@ -143,7 +145,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while enabling valve controller AP: %s", err) - async def async_pair_sensor(self, *, uid): + async def async_pair_sensor(self, *, uid: str) -> None: """Add a new paired sensor.""" try: async with self._client: @@ -156,7 +158,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._entry.entry_id ].async_pair_sensor(uid) - async def async_reboot(self): + async def async_reboot(self) -> None: """Reboot the device.""" try: async with self._client: @@ -164,7 +166,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while rebooting valve controller: %s", err) - async def async_reset_valve_diagnostics(self): + async def async_reset_valve_diagnostics(self) -> None: """Fully reset system motor diagnostics.""" try: async with self._client: @@ -172,7 +174,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while resetting valve diagnostics: %s", err) - async def async_unpair_sensor(self, *, uid): + async def async_unpair_sensor(self, *, uid: str) -> None: """Add a new paired sensor.""" try: async with self._client: @@ -185,7 +187,9 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._entry.entry_id ].async_unpair_sensor(uid) - async def async_upgrade_firmware(self, *, url, port, filename): + async def async_upgrade_firmware( + self, *, url: str, port: int, filename: str + ) -> None: """Upgrade the device firmware.""" try: async with self._client: @@ -197,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) -> None: + async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: """Turn the valve off (closed).""" try: async with self._client: @@ -209,7 +213,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._attr_is_on = False self.async_write_ha_state() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: """Turn the valve on (open).""" try: async with self._client: diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index beaf71dea51..c4d0e0be4d7 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable from datetime import timedelta -from typing import Callable +from typing import Any, Callable, Dict, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -42,11 +42,11 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): self._api_lock = api_lock self._client = client - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Execute a "locked" API request against the valve controller.""" async with self._api_lock, self._client: try: resp = await self._api_coro() except GuardianError as err: raise UpdateFailed(err) from err - return resp["data"] + return cast(Dict[str, Any], resp["data"]) diff --git a/mypy.ini b/mypy.ini index 32fff8d5105..e0bdc372f50 100644 --- a/mypy.ini +++ b/mypy.ini @@ -484,6 +484,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.guardian.*] +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.history.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1252,9 +1263,6 @@ ignore_errors = true [mypy-homeassistant.components.gtfs.*] ignore_errors = true -[mypy-homeassistant.components.guardian.*] -ignore_errors = true - [mypy-homeassistant.components.habitica.*] ignore_errors = true diff --git a/requirements_all.txt b/requirements_all.txt index 927a685ded4..fa3b4d2ba4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -172,7 +172,7 @@ aioflo==0.4.1 aioftp==0.12.0 # homeassistant.components.guardian -aioguardian==1.0.4 +aioguardian==1.0.8 # homeassistant.components.harmony aioharmony==0.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 101e0bbe4b0..eb34d203904 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioesphomeapi==5.0.1 aioflo==0.4.1 # homeassistant.components.guardian -aioguardian==1.0.4 +aioguardian==1.0.8 # homeassistant.components.harmony aioharmony==0.2.7 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 8dff2c8f89f..b1c74fceb45 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -61,7 +61,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", "homeassistant.components.gtfs.*", - "homeassistant.components.guardian.*", "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", From b9a6ce77d1602bfc365b24aac99deb38fa855048 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 22 Jul 2021 02:37:10 -0400 Subject: [PATCH 087/112] Bump zwave-js-server-python to 0.28.0 (#53302) --- homeassistant/components/zwave_js/const.py | 4 ---- homeassistant/components/zwave_js/entity.py | 8 +++---- .../components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/siren.py | 7 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_services.py | 13 +++++----- tests/components/zwave_js/test_siren.py | 1 + .../zwave_js/aeotec_zw164_siren_state.json | 24 ++++++++++++------- .../zwave_js/bulb_6_multi_color_state.json | 15 ++++++++---- 10 files changed, 45 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 4687110e208..ae5607745f6 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -70,7 +70,3 @@ ATTR_BROADCAST = "broadcast" SERVICE_PING = "ping" ADDON_SLUG = "core_zwave_js" - -# Siren constants -TONE_ID_DEFAULT = 255 -TONE_ID_OFF = 0 diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 432bc2fa868..6df7c8d546b 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.model.node import NodeStatus +from zwave_js_server.const import NodeStatus from zwave_js_server.model.value import Value as ZwaveValue, get_value_id from homeassistant.config_entries import ConfigEntry @@ -213,13 +213,13 @@ class ZWaveBaseEntity(Entity): # If we haven't found a value and check_all_endpoints is True, we should # return the first value we can find on any other endpoint if return_value is None and check_all_endpoints: - for endpoint_ in self.info.node.endpoints: - if endpoint_.index != self.info.primary_value.endpoint: + for endpoint_idx in self.info.node.endpoints: + if endpoint_idx != self.info.primary_value.endpoint: value_id = get_value_id( self.info.node, command_class, value_property, - endpoint=endpoint_.index, + endpoint=endpoint_idx, property_key=value_property_key, ) return_value = self.info.node.values.get(value_id) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index d719e3976a4..b24bc957303 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.27.1"], + "requirements": ["zwave-js-server-python==0.28.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["http", "websocket_api"], "iot_class": "local_push" diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index fa6e24878ed..de74f55fa9a 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -4,6 +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 homeassistant.components.siren import DOMAIN as SIREN_DOMAIN, SirenEntity from homeassistant.components.siren.const import ( @@ -19,7 +20,7 @@ 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, TONE_ID_DEFAULT, TONE_ID_OFF +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -87,7 +88,7 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): options["volume"] = round(volume * 100) # Play the default tone if a tone isn't provided if tone is None: - await self.async_set_value(TONE_ID_DEFAULT, options) + await self.async_set_value(ToneID.DEFAULT, options) return tone_id = int( @@ -102,4 +103,4 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.async_set_value(TONE_ID_OFF) + await self.async_set_value(ToneID.OFF) diff --git a/requirements_all.txt b/requirements_all.txt index fa3b4d2ba4c..32223f0fdfb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2461,4 +2461,4 @@ zigpy==0.35.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.27.1 +zwave-js-server-python==0.28.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb34d203904..2baf16de687 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1355,4 +1355,4 @@ zigpy-znp==0.5.1 zigpy==0.35.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.27.1 +zwave-js-server-python==0.28.0 diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 92ead72e2ad..4b66e178e6f 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -35,6 +35,7 @@ from .common import ( AEON_SMART_SWITCH_LIGHT_ENTITY, AIR_TEMPERATURE_SENSOR, CLIMATE_DANFOSS_LC13_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY, ) @@ -805,7 +806,7 @@ async def test_multicast_set_value( hass, client, climate_danfoss_lc_13, - climate_radio_thermostat_ct100_plus_different_endpoints, + climate_eurotronic_spirit_z, integration, ): """Test multicast_set_value service.""" @@ -816,7 +817,7 @@ async def test_multicast_set_value( { ATTR_ENTITY_ID: [ CLIMATE_DANFOSS_LC13_ENTITY, - CLIMATE_RADIO_THERMOSTAT_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, ], ATTR_COMMAND_CLASS: 117, ATTR_PROPERTY: "local", @@ -829,7 +830,7 @@ async def test_multicast_set_value( args = client.async_send_command.call_args[0][0] assert args["command"] == "multicast_group.set_value" assert args["nodeIDs"] == [ - climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + climate_eurotronic_spirit_z.node_id, climate_danfoss_lc_13.node_id, ] assert args["valueId"] == { @@ -847,7 +848,7 @@ async def test_multicast_set_value( { ATTR_ENTITY_ID: [ CLIMATE_DANFOSS_LC13_ENTITY, - CLIMATE_RADIO_THERMOSTAT_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, ], ATTR_COMMAND_CLASS: 117, ATTR_PROPERTY: "local", @@ -860,7 +861,7 @@ async def test_multicast_set_value( args = client.async_send_command.call_args[0][0] assert args["command"] == "multicast_group.set_value" assert args["nodeIDs"] == [ - climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + climate_eurotronic_spirit_z.node_id, climate_danfoss_lc_13.node_id, ] assert args["valueId"] == { @@ -937,7 +938,7 @@ async def test_multicast_set_value( { ATTR_ENTITY_ID: [ CLIMATE_DANFOSS_LC13_ENTITY, - CLIMATE_RADIO_THERMOSTAT_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, ], ATTR_COMMAND_CLASS: 117, ATTR_PROPERTY: "local", diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 23507e6a705..937b2c0fa67 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -53,6 +53,7 @@ TONE_ID_VALUE_ID = { "30": "30DOOR~1 (27 sec)", "255": "default", }, + "valueChangeOptions": ["volume"], }, } diff --git a/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json b/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json index 6bf7ece9758..5616abd6e0f 100644 --- a/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json +++ b/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json @@ -2597,7 +2597,8 @@ "writeable": true, "label": "Tone ID", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["volume"] } }, { @@ -2726,7 +2727,8 @@ "29": "29UPWA~1 (2 sec)", "30": "30DOOR~1 (27 sec)", "255": "default" - } + }, + "valueChangeOptions": ["volume"] } }, { @@ -2856,7 +2858,8 @@ "29": "29UPWA~1 (2 sec)", "30": "30DOOR~1 (27 sec)", "255": "default" - } + }, + "valueChangeOptions": ["volume"] } }, { @@ -3011,7 +3014,8 @@ "29": "29UPWA~1 (2 sec)", "30": "30DOOR~1 (27 sec)", "255": "default" - } + }, + "valueChangeOptions": ["volume"] } }, { @@ -3166,7 +3170,8 @@ "29": "29UPWA~1 (2 sec)", "30": "30DOOR~1 (27 sec)", "255": "default" - } + }, + "valueChangeOptions": ["volume"] } }, { @@ -3321,7 +3326,8 @@ "29": "29UPWA~1 (2 sec)", "30": "30DOOR~1 (27 sec)", "255": "default" - } + }, + "valueChangeOptions": ["volume"] } }, { @@ -3451,7 +3457,8 @@ "29": "29UPWA~1 (2 sec)", "30": "30DOOR~1 (27 sec)", "255": "default" - } + }, + "valueChangeOptions": ["volume"] } }, { @@ -3581,7 +3588,8 @@ "29": "29UPWA~1 (2 sec)", "30": "30DOOR~1 (27 sec)", "255": "default" - } + }, + "valueChangeOptions": ["volume"] } }, { 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 58608131e90..dfa72af6aa4 100644 --- a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json +++ b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json @@ -267,7 +267,8 @@ "min": 0, "max": 255, "label": "Target value (Warm White)", - "description": "The target value of the Warm White color." + "description": "The target value of the Warm White color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -285,7 +286,8 @@ "min": 0, "max": 255, "label": "Target value (Cold White)", - "description": "The target value of the Cold White color." + "description": "The target value of the Cold White color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -303,7 +305,8 @@ "min": 0, "max": 255, "label": "Target value (Red)", - "description": "The target value of the Red color." + "description": "The target value of the Red color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -321,7 +324,8 @@ "min": 0, "max": 255, "label": "Target value (Green)", - "description": "The target value of the Green color." + "description": "The target value of the Green color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -339,7 +343,8 @@ "min": 0, "max": 255, "label": "Target value (Blue)", - "description": "The target value of the Blue color." + "description": "The target value of the Blue color.", + "valueChangeOptions": ["transitionDuration"] } }, { From ce382a39d056d9f3a6210e1d17d03171f8bca627 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Jul 2021 23:37:33 -0700 Subject: [PATCH 088/112] Block title in strings.json unless internal or allowed (#53304) --- homeassistant/components/airnow/strings.json | 1 - .../components/apple_tv/strings.json | 1 - .../components/bosch_shc/strings.json | 3 +- .../components/climacell/strings.json | 1 - homeassistant/components/foscam/strings.json | 1 - .../components/habitica/strings.json | 33 +++++++------ .../components/home_plus_control/strings.json | 1 - .../components/kostal_plenticore/strings.json | 3 +- homeassistant/components/mazda/strings.json | 46 +++++++++---------- .../components/mysensors/strings.json | 1 - homeassistant/components/neato/strings.json | 5 +- homeassistant/components/picnic/strings.json | 3 +- homeassistant/components/sia/strings.json | 1 - .../components/srp_energy/strings.json | 1 - .../components/syncthing/strings.json | 1 - homeassistant/components/wallbox/strings.json | 3 +- .../components/zwave_js/strings.json | 3 +- script/hassfest/model.py | 10 ++++ script/hassfest/translations.py | 28 +++++++++++ 19 files changed, 83 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index a73ad6d179c..9fc5bd3bccc 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -1,5 +1,4 @@ { - "title": "AirNow", "config": { "step": { "user": { diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 00dd92cac89..d9fe17863dd 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -1,5 +1,4 @@ { - "title": "Apple TV", "config": { "flow_title": "{name}", "step": { diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index e7f090a4e1b..15fb061ef2b 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -1,5 +1,4 @@ { - "title": "Bosch SHC", "config": { "step": { "user": { @@ -35,4 +34,4 @@ }, "flow_title": "Bosch SHC: {name}" } -} \ No newline at end of file +} diff --git a/homeassistant/components/climacell/strings.json b/homeassistant/components/climacell/strings.json index f4347d254b7..44021f4b6d0 100644 --- a/homeassistant/components/climacell/strings.json +++ b/homeassistant/components/climacell/strings.json @@ -1,5 +1,4 @@ { - "title": "ClimaCell", "config": { "step": { "user": { diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 5c0622af9d1..14aa88b7952 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -1,5 +1,4 @@ { - "title": "Foscam", "config": { "step": { "user": { diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 868d024b02e..d25b840d761 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -1,20 +1,19 @@ { - "config": { - "error": { - "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "url": "[%key:common::config_flow::data::url%]", - "name": "Override for Habitica’s username. Will be used for service calls", - "api_user": "Habitica’s API user ID", - "api_key": "[%key:common::config_flow::data::api_key%]" - }, - "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" - } - } + "config": { + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, - "title": "Habitica" + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "name": "Override for Habitica’s username. Will be used for service calls", + "api_user": "Habitica’s API user ID", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" + } + } + } } diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index c991c9e0279..9e860b397fb 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -1,5 +1,4 @@ { - "title": "Legrand Home+ Control", "config": { "step": { "pick_implementation": { diff --git a/homeassistant/components/kostal_plenticore/strings.json b/homeassistant/components/kostal_plenticore/strings.json index 771c3ada744..30ce5af5a6c 100644 --- a/homeassistant/components/kostal_plenticore/strings.json +++ b/homeassistant/components/kostal_plenticore/strings.json @@ -1,5 +1,4 @@ { - "title": "Kostal Plenticore Solar Inverter", "config": { "step": { "user": { @@ -18,4 +17,4 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/mazda/strings.json b/homeassistant/components/mazda/strings.json index a7bed8725af..d2cc1bcfec9 100644 --- a/homeassistant/components/mazda/strings.json +++ b/homeassistant/components/mazda/strings.json @@ -1,26 +1,24 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - }, - "error": { - "account_locked": "Account locked. Please try again later.", - "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%]" - }, - "step": { - "user": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]", - "region": "Region" - }, - "description": "Please enter the email address and password you use to log into the MyMazda mobile app.", - "title": "Mazda Connected Services - Add Account" - } - } + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, - "title": "Mazda Connected Services" -} \ No newline at end of file + "error": { + "account_locked": "Account locked. Please try again later.", + "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%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" + }, + "description": "Please enter the email address and password you use to log into the MyMazda mobile app." + } + } + } +} diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index 54821877b4f..d7722e565cb 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -1,5 +1,4 @@ { - "title": "MySensors", "config": { "step": { "user": { diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 21af0f91d17..20848ccff08 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -18,6 +18,5 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } - }, - "title": "Neato Botvac" -} \ No newline at end of file + } +} diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index d43a91fbb0c..7fbd5e9bef6 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -1,5 +1,4 @@ { - "title": "Picnic", "config": { "step": { "user": { @@ -19,4 +18,4 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/sia/strings.json b/homeassistant/components/sia/strings.json index f837d41056a..fe648c24e75 100644 --- a/homeassistant/components/sia/strings.json +++ b/homeassistant/components/sia/strings.json @@ -1,5 +1,4 @@ { - "title": "SIA Alarm Systems", "config": { "step": { "user": { diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 8dce61229a9..3dddd961194 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -1,5 +1,4 @@ { - "title": "SRP Energy", "config": { "step": { "user": { diff --git a/homeassistant/components/syncthing/strings.json b/homeassistant/components/syncthing/strings.json index 1781df56f1e..36d1a688a70 100644 --- a/homeassistant/components/syncthing/strings.json +++ b/homeassistant/components/syncthing/strings.json @@ -1,5 +1,4 @@ { - "title": "Syncthing", "config": { "step": { "user": { diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 63fc5d89e85..6824a1343fc 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -1,5 +1,4 @@ { - "title": "Wallbox", "config": { "step": { "user": { @@ -19,4 +18,4 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 3d5aa277943..628451a6215 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,5 +1,4 @@ { - "title": "Z-Wave JS", "config": { "step": { "manual": { @@ -113,4 +112,4 @@ "value": "Current value of a Z-Wave Value" } } -} \ No newline at end of file +} diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 59d75be5c4a..b20df6ea42f 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -86,6 +86,16 @@ class Integration: """Return if integration is disabled.""" return self.manifest.get("disabled") + @property + def name(self) -> str: + """Return name of the integration.""" + return self.manifest["name"] + + @property + def quality_scale(self) -> str: + """Return quality scale of the integration.""" + return self.manifest.get("quality_scale") + @property def requirements(self) -> list[str]: """List of requirements.""" diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 4143d61ca5d..e24b37d71d9 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -21,6 +21,20 @@ REMOVED = 2 RE_REFERENCE = r"\[\%key:(.+)\%\]" +# Only allow translatino of integration names if they contain non-brand names +ALLOW_NAME_TRANSLATION = { + "cert_expiry", + "emulated_roku", + "garages_amsterdam", + "google_travel_time", + "homekit_controller", + "islamic_prayer_times", + "local_ip", + "nmap_tracker", + "rpi_power", + "waze_travel_time", +} + REMOVED_TITLE_MSG = ( "config.title key has been moved out of config and into the root of strings.json. " "Starting Home Assistant 0.109 you only need to define this key in the root " @@ -257,6 +271,20 @@ def validate_translation_file(config: Config, integration: Integration, all_stri if strings_file.name == "strings.json": find_references(strings, name, references) + if ( + integration.domain not in ALLOW_NAME_TRANSLATION + # Only enforce for core because custom integratinos can't be + # added to allow list. + and integration.core + and strings.get("title") == integration.name + and integration.quality_scale != "internal" + ): + integration.add_error( + "translations", + "Don't specify title in translation strings if it's a brand name " + "or add exception to ALLOW_NAME_TRANSLATION", + ) + platform_string_schema = gen_platform_strings_schema(config, integration) platform_strings = [integration.path.glob("strings.*.json")] From 4df928c1888813e539bafd76518b010f9f9a06fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jul 2021 20:38:55 -1000 Subject: [PATCH 089/112] Add support for updating the ISY ip address from discovery (#53290) Co-authored-by: Franck Nijhof --- .../components/isy994/config_flow.py | 64 ++++- homeassistant/components/isy994/const.py | 6 + tests/components/isy994/test_config_flow.py | 232 ++++++++++++++++++ 3 files changed, 292 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index c9ca29e8f63..58e5238cbee 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,6 +1,6 @@ """Config flow for Universal Devices ISY994 integration.""" import logging -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from aiohttp import CookieJar import async_timeout @@ -9,7 +9,7 @@ from pyisy.configuration import Configuration from pyisy.connection import Connection import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, core, data_entry_flow, exceptions from homeassistant.components import ssdp from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME @@ -28,7 +28,11 @@ from .const import ( DEFAULT_TLS_VERSION, DEFAULT_VAR_SENSOR_STRING, DOMAIN, + HTTP_PORT, + HTTPS_PORT, ISY_URL_POSTFIX, + SCHEME_HTTP, + SCHEME_HTTPS, UDN_UUID_PREFIX, ) @@ -58,15 +62,15 @@ async def validate_input(hass: core.HomeAssistant, data): host = urlparse(data[CONF_HOST]) tls_version = data.get(CONF_TLS_VER) - if host.scheme == "http": + if host.scheme == SCHEME_HTTP: https = False - port = host.port or 80 + port = host.port or HTTP_PORT session = aiohttp_client.async_create_clientsession( hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True) ) - elif host.scheme == "https": + elif host.scheme == SCHEME_HTTPS: https = True - port = host.port or 443 + port = host.port or HTTPS_PORT session = aiohttp_client.async_get_clientsession(hass) else: _LOGGER.error("The isy994 host value in configuration is invalid") @@ -150,6 +154,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle import.""" return await self.async_step_user(user_input) + async def _async_set_unique_id_or_update(self, isy_mac, ip_address, port) -> None: + """Abort and update the ip address on change.""" + existing_entry = await self.async_set_unique_id(isy_mac) + if not existing_entry: + return + parsed_url = urlparse(existing_entry.data[CONF_HOST]) + if parsed_url.hostname != ip_address: + new_netloc = ip_address + if port: + new_netloc = f"{ip_address}:{port}" + elif parsed_url.port: + new_netloc = f"{ip_address}:{parsed_url.port}" + self.hass.config_entries.async_update_entry( + existing_entry, + data={ + **existing_entry.data, + CONF_HOST: urlunparse( + ( + parsed_url.scheme, + new_netloc, + parsed_url.path, + parsed_url.query, + parsed_url.fragment, + None, + ) + ), + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + raise data_entry_flow.AbortFlow("already_configured") + async def async_step_dhcp(self, discovery_info): """Handle a discovered isy994 via dhcp.""" friendly_name = discovery_info[HOSTNAME] @@ -158,8 +195,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): isy_mac = ( f"{mac[0:2]}:{mac[2:4]}:{mac[4:6]}:{mac[6:8]}:{mac[8:10]}:{mac[10:12]}" ) - await self.async_set_unique_id(isy_mac) - self._abort_if_unique_id_configured() + await self._async_set_unique_id_or_update( + isy_mac, discovery_info[IP_ADDRESS], None + ) self.discovered_conf = { CONF_NAME: friendly_name, @@ -173,14 +211,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a discovered isy994.""" friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] url = discovery_info[ssdp.ATTR_SSDP_LOCATION] + parsed_url = urlparse(url) mac = discovery_info[ssdp.ATTR_UPNP_UDN] if mac.startswith(UDN_UUID_PREFIX): mac = mac[len(UDN_UUID_PREFIX) :] if url.endswith(ISY_URL_POSTFIX): url = url[: -len(ISY_URL_POSTFIX)] - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured() + port = HTTP_PORT + if parsed_url.port: + port = parsed_url.port + elif parsed_url.scheme == SCHEME_HTTPS: + port = HTTPS_PORT + + await self._async_set_unique_id_or_update(mac, parsed_url.hostname, port) self.discovered_conf = { CONF_NAME: friendly_name, diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 343f01332f2..b7b2f283a84 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -672,3 +672,9 @@ BINARY_SENSOR_DEVICE_TYPES_ZWAVE = { DEVICE_CLASS_MOTION: ["155"], DEVICE_CLASS_VIBRATION: ["173"], } + + +SCHEME_HTTP = "http" +HTTP_PORT = 80 +SCHEME_HTTPS = "https" +HTTPS_PORT = 443 diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index e5458a3c96b..1e96de9ff2f 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -156,6 +156,24 @@ async def test_form_invalid_auth(hass: HomeAssistant): assert result2["errors"] == {"base": "invalid_auth"} +async def test_form_unknown_exeption(hass: HomeAssistant): + """Test we handle generic exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + PATCH_CONNECTION, + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + async def test_form_isy_connection_error(hass: HomeAssistant): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -355,6 +373,146 @@ async def test_form_ssdp(hass: HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_ssdp_existing_entry(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://3.3.3.3:80{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp with no port.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}:1443/{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3/{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://3.3.3.3:80/{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp with an alternate port.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}:1443/{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp with no port and https.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"https://{MOCK_HOSTNAME}/{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"https://3.3.3.3/{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"https://3.3.3.3:443/{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_dhcp(hass: HomeAssistant): """Test we can setup from dhcp.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -390,3 +548,77 @@ async def test_form_dhcp(hass: HomeAssistant): assert result2["data"] == MOCK_USER_INPUT assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_dhcp_existing_entry(hass: HomeAssistant): + """Test we update the ip of an existing entry from dhcp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data={ + dhcp.IP_ADDRESS: "1.2.3.4", + dhcp.HOSTNAME: "isy994-ems", + dhcp.MAC_ADDRESS: MOCK_MAC, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://1.2.3.4{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): + """Test we update the ip of an existing entry from dhcp preserves port.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "bob", + CONF_HOST: f"http://{MOCK_HOSTNAME}:1443{ISY_URL_POSTFIX}", + }, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data={ + dhcp.IP_ADDRESS: "1.2.3.4", + dhcp.HOSTNAME: "isy994-ems", + dhcp.MAC_ADDRESS: MOCK_MAC, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://1.2.3.4:1443{ISY_URL_POSTFIX}" + assert entry.data[CONF_USERNAME] == "bob" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From 804499968ee6b5967b45ca7825f054a39fb405c9 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 22 Jul 2021 02:51:14 -0400 Subject: [PATCH 090/112] Use entity class attributes for Bluesound (#53033) * Use entity class attributes for bluesound * rework * tweak * tweak --- .../components/bluesound/media_player.py | 185 +++++++----------- 1 file changed, 70 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 86d0be72bdc..a565a0f560c 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -203,33 +203,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" - def __init__(self, hass, host, port=None, name=None, init_callback=None): + _attr_media_content_type = MEDIA_TYPE_MUSIC + + def __init__(self, hass, host, port=DEFAULT_PORT, 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._name = name - self._icon = None + self._attr_name = name self._capture_items = [] self._services_items = [] self._preset_items = [] self._sync_status = {} self._status = None - self._last_status_update = None - self._is_online = False + self._is_online = None self._retry_remove = None - self._muted = False self._master = None - self._is_master = False self._group_name = None - self._group_list = [] self._bluesound_device_name = None - + self._is_master = False + self._group_list = [] self._init_callback = init_callback - if self.port is None: - self.port = DEFAULT_PORT class _TimeoutException(Exception): pass @@ -252,12 +248,12 @@ class BluesoundPlayer(MediaPlayerEntity): return None self._sync_status = resp["SyncStatus"].copy() - if not self._name: - self._name = self._sync_status.get("@name", self.host) + if not self.name: + self._attr_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._icon = self._sync_status.get("@icon", self.host) + if not self.icon: + self._attr_icon = self._sync_status.get("@icon", self.host) master = self._sync_status.get("master") if master is not None: @@ -291,14 +287,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): @@ -402,7 +398,7 @@ class BluesoundPlayer(MediaPlayerEntity): if response.status == HTTP_OK: result = await response.text() self._is_online = True - self._last_status_update = dt_util.utcnow() + self._attr_media_position_updated_at = dt_util.utcnow() self._status = xmltodict.parse(result)["status"].copy() group_name = self._status.get("groupName") @@ -438,11 +434,58 @@ class BluesoundPlayer(MediaPlayerEntity): except (asyncio.TimeoutError, ClientError): self._is_online = False - self._last_status_update = None + self._attr_media_position_updated_at = 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.""" @@ -542,27 +585,6 @@ 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.""" @@ -617,7 +639,7 @@ class BluesoundPlayer(MediaPlayerEntity): return None mediastate = self.state - if self._last_status_update is None or mediastate == STATE_IDLE: + if self.media_position_updated_at is None or mediastate == STATE_IDLE: return None position = self._status.get("secs") @@ -626,7 +648,9 @@ class BluesoundPlayer(MediaPlayerEntity): position = float(position) if mediastate == STATE_PLAYING: - position += (dt_util.utcnow() - self._last_status_update).total_seconds() + position += ( + dt_util.utcnow() - self.media_position_updated_at + ).total_seconds() return position @@ -641,11 +665,6 @@ 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).""" @@ -668,21 +687,11 @@ 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.""" @@ -778,58 +787,15 @@ class BluesoundPlayer(MediaPlayerEntity): return None @property - 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): + def is_master(self) -> bool: """Return true if player is a coordinator.""" return self._is_master @property - def is_grouped(self): + def is_grouped(self) -> bool: """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 = [ @@ -849,17 +815,6 @@ 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 9753500f5eaff7354b218a405d34a43edc904c4e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 22 Jul 2021 08:57:29 +0200 Subject: [PATCH 091/112] Disable speeds for first gen Xiaomi_miio air purifiers (#52772) * Disable speeds for first gen air purifiers * Remove test code line * remove OPERATION_MODES_AIRPURIFIER list --- homeassistant/components/xiaomi_miio/fan.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index c1dde7ec38d..bdef9517cca 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -336,7 +336,6 @@ AVAILABLE_ATTRIBUTES_AIRFRESH = { ATTR_EXTRA_FEATURES: "extra_features", } -OPERATION_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] PRESET_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] OPERATION_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] @@ -903,10 +902,10 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._device_features = FEATURE_FLAGS_AIRPURIFIER self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER - self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE - self._speed_count = 4 + 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 + self._speed_list = [] self._state_attrs.update( {attribute: None for attribute in self._available_attributes} From 7768f53281a6d294a55445a52efd967d8e30098b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 10:36:29 +0200 Subject: [PATCH 092/112] Use NamedTuple - brother (#53330) --- homeassistant/components/brother/const.py | 352 ++++++++++----------- homeassistant/components/brother/model.py | 12 +- homeassistant/components/brother/sensor.py | 27 +- 3 files changed, 190 insertions(+), 201 deletions(-) diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index c0021df11fc..727a67d9093 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -3,15 +3,10 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - DEVICE_CLASS_TIMESTAMP, - PERCENTAGE, -) +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE -from .model import SensorDescription +from .model import BrotherSensorMetadata ATTR_BELT_UNIT_REMAINING_LIFE: Final = "belt_unit_remaining_life" ATTR_BLACK_DRUM_COUNTER: Final = "black_drum_counter" @@ -31,9 +26,7 @@ ATTR_DRUM_COUNTER: Final = "drum_counter" ATTR_DRUM_REMAINING_LIFE: Final = "drum_remaining_life" ATTR_DRUM_REMAINING_PAGES: Final = "drum_remaining_pages" ATTR_DUPLEX_COUNTER: Final = "duplex_unit_pages_counter" -ATTR_ENABLED: Final = "enabled" ATTR_FUSER_REMAINING_LIFE: Final = "fuser_remaining_life" -ATTR_LABEL: Final = "label" ATTR_LASER_REMAINING_LIFE: Final = "laser_remaining_life" ATTR_MAGENTA_DRUM_COUNTER: Final = "magenta_drum_counter" ATTR_MAGENTA_DRUM_REMAINING_LIFE: Final = "magenta_drum_remaining_life" @@ -46,7 +39,6 @@ ATTR_PF_KIT_1_REMAINING_LIFE: Final = "pf_kit_1_remaining_life" ATTR_PF_KIT_MP_REMAINING_LIFE: Final = "pf_kit_mp_remaining_life" ATTR_REMAINING_PAGES: Final = "remaining_pages" ATTR_STATUS: Final = "status" -ATTR_UNIT: Final = "unit" ATTR_UPTIME: Final = "uptime" ATTR_YELLOW_DRUM_COUNTER: Final = "yellow_drum_counter" ATTR_YELLOW_DRUM_REMAINING_LIFE: Final = "yellow_drum_remaining_life" @@ -84,174 +76,172 @@ ATTRS_MAP: Final[dict[str, tuple[str, str]]] = { ), } -SENSOR_TYPES: Final[dict[str, SensorDescription]] = { - ATTR_STATUS: { - ATTR_ICON: "mdi:printer", - ATTR_LABEL: ATTR_STATUS.title(), - ATTR_UNIT: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: None, - }, - ATTR_PAGE_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_PAGE_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BW_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_BW_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_COLOR_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_COLOR_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_DUPLEX_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_DUPLEX_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BLACK_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_CYAN_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_MAGENTA_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_YELLOW_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BELT_UNIT_REMAINING_LIFE: { - ATTR_ICON: "mdi:current-ac", - ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_FUSER_REMAINING_LIFE: { - ATTR_ICON: "mdi:water-outline", - ATTR_LABEL: ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_LASER_REMAINING_LIFE: { - ATTR_ICON: "mdi:spotlight-beam", - ATTR_LABEL: ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_PF_KIT_1_REMAINING_LIFE: { - ATTR_ICON: "mdi:printer-3d", - ATTR_LABEL: ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_PF_KIT_MP_REMAINING_LIFE: { - ATTR_ICON: "mdi:printer-3d", - ATTR_LABEL: ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BLACK_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_CYAN_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_MAGENTA_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_YELLOW_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BLACK_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_CYAN_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_MAGENTA_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_YELLOW_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_UPTIME: { - ATTR_ICON: None, - ATTR_LABEL: ATTR_UPTIME.title(), - ATTR_UNIT: None, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, +SENSOR_TYPES: Final[dict[str, BrotherSensorMetadata]] = { + ATTR_STATUS: BrotherSensorMetadata( + icon="mdi:printer", + label=ATTR_STATUS.title(), + unit_of_measurement=None, + enabled=True, + ), + ATTR_PAGE_COUNTER: BrotherSensorMetadata( + icon="mdi:file-document-outline", + label=ATTR_PAGE_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_BW_COUNTER: BrotherSensorMetadata( + icon="mdi:file-document-outline", + label=ATTR_BW_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_COLOR_COUNTER: BrotherSensorMetadata( + icon="mdi:file-document-outline", + label=ATTR_COLOR_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_DUPLEX_COUNTER: BrotherSensorMetadata( + icon="mdi:file-document-outline", + label=ATTR_DUPLEX_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_DRUM_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:chart-donut", + label=ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_BLACK_DRUM_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:chart-donut", + label=ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_CYAN_DRUM_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:chart-donut", + label=ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_MAGENTA_DRUM_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:chart-donut", + label=ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_YELLOW_DRUM_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:chart-donut", + label=ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_BELT_UNIT_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:current-ac", + label=ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_FUSER_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:water-outline", + label=ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_LASER_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:spotlight-beam", + label=ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_PF_KIT_1_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:printer-3d", + label=ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_PF_KIT_MP_REMAINING_LIFE: BrotherSensorMetadata( + icon="mdi:printer-3d", + label=ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_BLACK_TONER_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_CYAN_TONER_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_MAGENTA_TONER_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_YELLOW_TONER_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_BLACK_INK_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_CYAN_INK_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_MAGENTA_INK_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_YELLOW_INK_REMAINING: BrotherSensorMetadata( + icon="mdi:printer-3d-nozzle", + label=ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + enabled=True, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_UPTIME: BrotherSensorMetadata( + icon=None, + label=ATTR_UPTIME.title(), + unit_of_measurement=None, + enabled=False, + device_class=DEVICE_CLASS_TIMESTAMP, + ), } diff --git a/homeassistant/components/brother/model.py b/homeassistant/components/brother/model.py index ab8df09b749..a1fcc83aae9 100644 --- a/homeassistant/components/brother/model.py +++ b/homeassistant/components/brother/model.py @@ -1,15 +1,15 @@ """Type definitions for Brother integration.""" from __future__ import annotations -from typing import TypedDict +from typing import NamedTuple -class SensorDescription(TypedDict, total=False): - """Sensor description class.""" +class BrotherSensorMetadata(NamedTuple): + """Metadata for an individual Brother sensor.""" icon: str | None label: str - unit: str | None + unit_of_measurement: str | None enabled: bool - state_class: str | None - device_class: str | None + state_class: str | None = None + device_class: str | None = None diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 38fac529076..90a73f1bd9b 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -3,9 +3,8 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -14,17 +13,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BrotherDataUpdateCoordinator from .const import ( ATTR_COUNTER, - ATTR_ENABLED, - ATTR_LABEL, ATTR_MANUFACTURER, ATTR_REMAINING_PAGES, - ATTR_UNIT, ATTR_UPTIME, ATTRS_MAP, DATA_CONFIG_ENTRY, DOMAIN, SENSOR_TYPES, ) +from .model import BrotherSensorMetadata async def async_setup_entry( @@ -43,9 +40,11 @@ async def async_setup_entry( "sw_version": getattr(coordinator.data, "firmware", None), } - for sensor in SENSOR_TYPES: + for sensor, metadata in SENSOR_TYPES.items(): if sensor in coordinator.data: - sensors.append(BrotherPrinterSensor(coordinator, sensor, device_info)) + sensors.append( + BrotherPrinterSensor(coordinator, sensor, metadata, device_info) + ) async_add_entities(sensors, False) @@ -56,20 +55,20 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): self, coordinator: BrotherDataUpdateCoordinator, kind: str, + metadata: BrotherSensorMetadata, device_info: DeviceInfo, ) -> None: """Initialize.""" super().__init__(coordinator) - description = SENSOR_TYPES[kind] self._attrs: dict[str, Any] = {} - self._attr_device_class = description.get(ATTR_DEVICE_CLASS) + self._attr_device_class = metadata.device_class self._attr_device_info = device_info - self._attr_entity_registry_enabled_default = description[ATTR_ENABLED] - self._attr_icon = description[ATTR_ICON] - self._attr_name = f"{coordinator.data.model} {description[ATTR_LABEL]}" - self._attr_state_class = description[ATTR_STATE_CLASS] + self._attr_entity_registry_enabled_default = metadata.enabled + self._attr_icon = metadata.icon + self._attr_name = f"{coordinator.data.model} {metadata.label}" + self._attr_state_class = metadata.state_class self._attr_unique_id = f"{coordinator.data.serial.lower()}_{kind}" - self._attr_unit_of_measurement = description[ATTR_UNIT] + self._attr_unit_of_measurement = metadata.unit_of_measurement self.kind = kind @property From 1a450c208445c6022462c0674c60da64b8a95c68 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 22 Jul 2021 13:25:54 +0300 Subject: [PATCH 093/112] Speedtestdotnet code cleanup and type hints (#52533) --- .coveragerc | 1 - .../components/speedtestdotnet/__init__.py | 104 ++++++------- .../components/speedtestdotnet/config_flow.py | 23 ++- .../components/speedtestdotnet/const.py | 37 +++-- .../components/speedtestdotnet/sensor.py | 50 +++--- .../components/speedtestdotnet/strings.json | 5 +- .../speedtestdotnet/translations/en.json | 3 +- tests/components/speedtestdotnet/conftest.py | 16 ++ .../speedtestdotnet/test_config_flow.py | 146 +++++++++--------- tests/components/speedtestdotnet/test_init.py | 122 +++++++++------ .../components/speedtestdotnet/test_sensor.py | 17 +- 11 files changed, 287 insertions(+), 237 deletions(-) create mode 100644 tests/components/speedtestdotnet/conftest.py diff --git a/.coveragerc b/.coveragerc index 44bb49e5f57..2693080986b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -969,7 +969,6 @@ omit = homeassistant/components/sonos/* homeassistant/components/sony_projector/switch.py homeassistant/components/spc/* - homeassistant/components/speedtestdotnet/* homeassistant/components/spider/* homeassistant/components/splunk/* homeassistant/components/spotify/__init__.py diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 71e51c0959d..b049b3a2d2c 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -1,19 +1,22 @@ """Support for testing internet speed via Speedtest.net.""" +from __future__ import annotations + from datetime import timedelta import logging import speedtest import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED, ) -from homeassistant.core import CoreState, callback +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -22,6 +25,7 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_SERVER, DOMAIN, + PLATFORMS, SENSOR_TYPES, SPEED_TEST_SERVICE, ) @@ -51,10 +55,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["sensor"] - -def server_id_valid(server_id): +def server_id_valid(server_id: str) -> bool: """Check if server_id is valid.""" try: api = speedtest.Speedtest() @@ -65,7 +67,7 @@ def server_id_valid(server_id): return True -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Import integration from config.""" if DOMAIN in config: hass.async_create_task( @@ -76,7 +78,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Speedtest.net component.""" coordinator = SpeedTestDataCoordinator(hass, config_entry) await coordinator.async_setup() @@ -88,11 +90,9 @@ async def async_setup_entry(hass, config_entry): ) await coordinator.async_refresh() - if not config_entry.options[CONF_MANUAL]: + if not config_entry.options.get(CONF_MANUAL, False): if hass.state == CoreState.running: await _enable_scheduled_speedtests() - if not coordinator.last_update_success: - raise ConfigEntryNotReady else: # Running a speed test during startup can prevent # integrations from being able to setup because it @@ -108,12 +108,10 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload SpeedTest Entry from config_entry.""" hass.services.async_remove(DOMAIN, SPEED_TEST_SERVICE) - hass.data[DOMAIN].async_unload() - unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) @@ -125,13 +123,12 @@ async def async_unload_entry(hass, config_entry): class SpeedTestDataCoordinator(DataUpdateCoordinator): """Get the latest data from speedtest.net.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the data object.""" self.hass = hass - self.config_entry = config_entry - self.api = None - self.servers = {} - self._unsub_update_listener = None + self.config_entry: ConfigEntry = config_entry + self.api: speedtest.Speedtest | None = None + self.servers: dict[str, dict] = {DEFAULT_SERVER: {}} super().__init__( self.hass, _LOGGER, @@ -141,51 +138,49 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): def update_servers(self): """Update list of test servers.""" - try: - server_list = self.api.get_servers() - except speedtest.ConfigRetrievalError: - _LOGGER.debug("Error retrieving server list") - return - - self.servers[DEFAULT_SERVER] = {} - for server in sorted( - server_list.values(), - key=lambda server: server[0]["country"] + server[0]["sponsor"], - ): - self.servers[ - f"{server[0]['country']} - {server[0]['sponsor']} - {server[0]['name']}" - ] = server[0] + test_servers = self.api.get_servers() + test_servers_list = [] + for servers in test_servers.values(): + for server in servers: + test_servers_list.append(server) + if test_servers_list: + for server in sorted( + test_servers_list, + key=lambda server: ( + server["country"], + server["name"], + server["sponsor"], + ), + ): + self.servers[ + f"{server['country']} - {server['sponsor']} - {server['name']}" + ] = server def update_data(self): """Get the latest data from speedtest.net.""" self.update_servers() - self.api.closest.clear() if self.config_entry.options.get(CONF_SERVER_ID): server_id = self.config_entry.options.get(CONF_SERVER_ID) self.api.get_servers(servers=[server_id]) - try: - self.api.get_best_server() - except speedtest.SpeedtestBestServerFailure as err: - raise UpdateFailed( - "Failed to retrieve best server for speedtest", err - ) from err - + best_server = self.api.get_best_server() _LOGGER.debug( "Executing speedtest.net speed test with server_id: %s", - self.api.best["id"], + best_server["id"], ) self.api.download() self.api.upload() return self.api.results.dict() - async def async_update(self, *_): + async def async_update(self) -> dict[str, str]: """Update Speedtest data.""" try: return await self.hass.async_add_executor_job(self.update_data) - except (speedtest.ConfigRetrievalError, speedtest.NoMatchedServers) as err: - raise UpdateFailed from err + except speedtest.NoMatchedServers as err: + raise UpdateFailed("Selected server is not found.") from err + except speedtest.SpeedtestException as err: + raise UpdateFailed(err) from err async def async_set_options(self): """Set options for entry.""" @@ -200,11 +195,12 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): self.config_entry, data=data, options=options ) - async def async_setup(self): + async def async_setup(self) -> None: """Set up SpeedTest.""" try: self.api = await self.hass.async_add_executor_job(speedtest.Speedtest) - except speedtest.ConfigRetrievalError as err: + await self.hass.async_add_executor_job(self.update_servers) + except speedtest.SpeedtestException as err: raise ConfigEntryNotReady from err async def request_update(call): @@ -213,24 +209,14 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): await self.async_set_options() - await self.hass.async_add_executor_job(self.update_servers) - self.hass.services.async_register(DOMAIN, SPEED_TEST_SERVICE, request_update) - self._unsub_update_listener = self.config_entry.add_update_listener( - options_updated_listener + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(options_updated_listener) ) - @callback - def async_unload(self): - """Unload the coordinator.""" - if not self._unsub_update_listener: - return - self._unsub_update_listener() - self._unsub_update_listener = None - -async def options_updated_listener(hass, entry): +async def options_updated_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" if entry.options[CONF_MANUAL]: hass.data[DOMAIN].update_interval = None diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 49654b6c02b..e5462aa9379 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -1,9 +1,14 @@ """Config flow for Speedtest.net.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from . import server_id_valid from .const import ( @@ -24,11 +29,15 @@ class SpeedTestFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: """Get the options flow for this handler.""" return SpeedTestOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -59,14 +68,16 @@ class SpeedTestFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class SpeedTestOptionsFlowHandler(config_entries.OptionsFlow): """Handle SpeedTest options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - self._servers = {} + self._servers: dict = {} - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: server_name = user_input[CONF_SERVER_NAME] diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 546c7db053b..04f3ea0cc55 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,32 +1,35 @@ """Consts used by Speedtest.net.""" +from typing import Final + from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND, TIME_MILLISECONDS -DOMAIN = "speedtestdotnet" +DOMAIN: Final = "speedtestdotnet" -SPEED_TEST_SERVICE = "speedtest" -DATA_UPDATED = f"{DOMAIN}_data_updated" +SPEED_TEST_SERVICE: Final = "speedtest" -SENSOR_TYPES = { +SENSOR_TYPES: Final = { "ping": ["Ping", TIME_MILLISECONDS], "download": ["Download", DATA_RATE_MEGABITS_PER_SECOND], "upload": ["Upload", DATA_RATE_MEGABITS_PER_SECOND], } -CONF_SERVER_NAME = "server_name" -CONF_SERVER_ID = "server_id" -CONF_MANUAL = "manual" +CONF_SERVER_NAME: Final = "server_name" +CONF_SERVER_ID: Final = "server_id" +CONF_MANUAL: Final = "manual" -ATTR_BYTES_RECEIVED = "bytes_received" -ATTR_BYTES_SENT = "bytes_sent" -ATTR_SERVER_COUNTRY = "server_country" -ATTR_SERVER_ID = "server_id" -ATTR_SERVER_NAME = "server_name" +ATTR_BYTES_RECEIVED: Final = "bytes_received" +ATTR_BYTES_SENT: Final = "bytes_sent" +ATTR_SERVER_COUNTRY: Final = "server_country" +ATTR_SERVER_ID: Final = "server_id" +ATTR_SERVER_NAME: Final = "server_name" -DEFAULT_NAME = "SpeedTest" -DEFAULT_SCAN_INTERVAL = 60 -DEFAULT_SERVER = "*Auto Detect" +DEFAULT_NAME: Final = "SpeedTest" +DEFAULT_SCAN_INTERVAL: Final = 60 +DEFAULT_SERVER: Final = "*Auto Detect" -ATTRIBUTION = "Data retrieved from Speedtest.net by Ookla" +ATTRIBUTION: Final = "Data retrieved from Speedtest.net by Ookla" -ICON = "mdi:speedometer" +ICON: Final = "mdi:speedometer" + +PLATFORMS: Final = ["sensor"] diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index e28aa0b2527..8dcc5bc3459 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -54,26 +54,28 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): 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._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - if not self.coordinator.data: - return None + if self.coordinator.data: + self._attrs.update( + { + ATTR_SERVER_NAME: self.coordinator.data["server"]["name"], + ATTR_SERVER_COUNTRY: self.coordinator.data["server"]["country"], + ATTR_SERVER_ID: self.coordinator.data["server"]["id"], + } + ) - attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_SERVER_NAME: self.coordinator.data["server"]["name"], - ATTR_SERVER_COUNTRY: self.coordinator.data["server"]["country"], - ATTR_SERVER_ID: self.coordinator.data["server"]["id"], - } + if self.type == "download": + self._attrs[ATTR_BYTES_RECEIVED] = self.coordinator.data[ + "bytes_received" + ] + elif self.type == "upload": + self._attrs[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] - if self.type == "download": - attributes[ATTR_BYTES_RECEIVED] = self.coordinator.data["bytes_received"] - elif self.type == "upload": - attributes[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] - - return attributes + return self._attrs async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -91,14 +93,12 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self.async_on_remove(self.coordinator.async_add_listener(update)) self._update_state() - def _update_state(self) -> None: + def _update_state(self): """Update sensors state.""" - if not self.coordinator.data: - return - - if self.type == "ping": - self._attr_state = self.coordinator.data["ping"] - elif self.type == "download": - self._attr_state = round(self.coordinator.data["download"] / 10 ** 6, 2) - elif self.type == "upload": - self._attr_state = round(self.coordinator.data["upload"] / 10 ** 6, 2) + if self.coordinator.data: + if self.type == "ping": + self._attr_state = self.coordinator.data["ping"] + elif self.type == "download": + self._attr_state = round(self.coordinator.data["download"] / 10 ** 6, 2) + elif self.type == "upload": + self._attr_state = round(self.coordinator.data["upload"] / 10 ** 6, 2) diff --git a/homeassistant/components/speedtestdotnet/strings.json b/homeassistant/components/speedtestdotnet/strings.json index cf3587af6c5..c4dad30cb09 100644 --- a/homeassistant/components/speedtestdotnet/strings.json +++ b/homeassistant/components/speedtestdotnet/strings.json @@ -6,8 +6,7 @@ } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "wrong_server_id": "Server ID is not valid" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "options": { @@ -21,4 +20,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/speedtestdotnet/translations/en.json b/homeassistant/components/speedtestdotnet/translations/en.json index b56ff193e33..eab480073bc 100644 --- a/homeassistant/components/speedtestdotnet/translations/en.json +++ b/homeassistant/components/speedtestdotnet/translations/en.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Already configured. Only a single configuration possible.", - "wrong_server_id": "Server ID is not valid" + "single_instance_allowed": "Already configured. Only a single configuration possible." }, "step": { "user": { diff --git a/tests/components/speedtestdotnet/conftest.py b/tests/components/speedtestdotnet/conftest.py new file mode 100644 index 00000000000..78a864cb934 --- /dev/null +++ b/tests/components/speedtestdotnet/conftest.py @@ -0,0 +1,16 @@ +"""Conftest for speedtestdotnet.""" +from unittest.mock import patch + +import pytest + +from tests.components.speedtestdotnet import MOCK_RESULTS, MOCK_SERVERS + + +@pytest.fixture(autouse=True) +def mock_api(): + """Mock entry setup.""" + with patch("speedtest.Speedtest") as mock_api: + mock_api.return_value.get_servers.return_value = MOCK_SERVERS + mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] + mock_api.return_value.results.dict.return_value = MOCK_RESULTS + yield mock_api diff --git a/tests/components/speedtestdotnet/test_config_flow.py b/tests/components/speedtestdotnet/test_config_flow.py index a7a65511ee5..727a5778603 100644 --- a/tests/components/speedtestdotnet/test_config_flow.py +++ b/tests/components/speedtestdotnet/test_config_flow.py @@ -1,8 +1,7 @@ """Tests for SpeedTest config flow.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock -import pytest from speedtest import NoMatchedServers from homeassistant import config_entries, data_entry_flow @@ -15,23 +14,12 @@ from homeassistant.components.speedtestdotnet.const import ( SENSOR_TYPES, ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL - -from . import MOCK_SERVERS +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.fixture(name="mock_setup") -def mock_setup(): - """Mock entry setup.""" - with patch( - "homeassistant.components.speedtestdotnet.async_setup_entry", - return_value=True, - ): - yield - - -async def test_flow_works(hass, mock_setup): +async def test_flow_works(hass: HomeAssistant) -> None: """Test user config.""" result = await hass.config_entries.flow.async_init( speedtestdotnet.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,92 +31,104 @@ async def test_flow_works(hass, mock_setup): result["flow_id"], user_input={} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "SpeedTest" -async def test_import_fails(hass, mock_setup): +async def test_import_fails(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test import step fails if server_id is not valid.""" - with patch("speedtest.Speedtest") as mock_api: - mock_api.return_value.get_servers.side_effect = NoMatchedServers - result = await hass.config_entries.flow.async_init( - speedtestdotnet.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_SERVER_ID: "223", - CONF_MANUAL: True, - CONF_SCAN_INTERVAL: timedelta(minutes=1), - CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "wrong_server_id" + mock_api.return_value.get_servers.side_effect = NoMatchedServers + result = await hass.config_entries.flow.async_init( + speedtestdotnet.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_SERVER_ID: "223", + CONF_MANUAL: True, + CONF_SCAN_INTERVAL: timedelta(minutes=1), + CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "wrong_server_id" -async def test_import_success(hass, mock_setup): +async def test_import_success(hass): """Test import step is successful if server_id is valid.""" - with patch("speedtest.Speedtest"): - result = await hass.config_entries.flow.async_init( - speedtestdotnet.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_SERVER_ID: "1", - CONF_MANUAL: True, - CONF_SCAN_INTERVAL: timedelta(minutes=1), - CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), - }, - ) + result = await hass.config_entries.flow.async_init( + speedtestdotnet.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_SERVER_ID: "1", + CONF_MANUAL: True, + CONF_SCAN_INTERVAL: timedelta(minutes=1), + CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), + }, + ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "SpeedTest" - assert result["data"][CONF_SERVER_ID] == "1" - assert result["data"][CONF_MANUAL] is True - assert result["data"][CONF_SCAN_INTERVAL] == 1 + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "SpeedTest" + assert result["data"][CONF_SERVER_ID] == "1" + assert result["data"][CONF_MANUAL] is True + assert result["data"][CONF_SCAN_INTERVAL] == 1 -async def test_options(hass): +async def test_options(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test updating options.""" entry = MockConfigEntry( domain=DOMAIN, title="SpeedTest", - data={}, - options={}, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest") as mock_api: - mock_api.return_value.get_servers.return_value = MOCK_SERVERS - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(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_SERVER_NAME: "Country1 - Sponsor1 - Server1", - CONF_SCAN_INTERVAL: 30, - CONF_MANUAL: False, - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == { + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SERVER_ID: "1", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + } + await hass.async_block_till_done() + + assert hass.data[DOMAIN].update_interval is None + + # test setting the option to update periodically + result2 = await hass.config_entries.options.async_init(entry.entry_id) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", - CONF_SERVER_ID: "1", CONF_SCAN_INTERVAL: 30, CONF_MANUAL: False, - } + }, + ) + await hass.async_block_till_done() + + assert hass.data[DOMAIN].update_interval == timedelta(minutes=30) -async def test_integration_already_configured(hass): +async def test_integration_already_configured(hass: HomeAssistant) -> None: """Test integration is already configured.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, - options={}, ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index 30d3d2a1d63..fcadb0e9931 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -1,79 +1,113 @@ """Tests for SpeedTest integration.""" -from unittest.mock import patch +from unittest.mock import MagicMock import speedtest -from homeassistant import config_entries -from homeassistant.components import speedtestdotnet -from homeassistant.setup import async_setup_component +from homeassistant.components.speedtestdotnet.const import ( + CONF_MANUAL, + CONF_SERVER_ID, + CONF_SERVER_NAME, + DOMAIN, + SPEED_TEST_SERVICE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_SCAN_INTERVAL, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_setup_with_config(hass): - """Test that we import the config and setup the integration.""" - config = { - speedtestdotnet.DOMAIN: { - speedtestdotnet.CONF_SERVER_ID: "1", - speedtestdotnet.CONF_MANUAL: True, - speedtestdotnet.CONF_SCAN_INTERVAL: "00:01:00", - } - } - with patch("speedtest.Speedtest"): - assert await async_setup_component(hass, speedtestdotnet.DOMAIN, config) - - -async def test_successful_config_entry(hass): +async def test_successful_config_entry(hass: HomeAssistant) -> None: """Test that SpeedTestDotNet is configured successfully.""" entry = MockConfigEntry( - domain=speedtestdotnet.DOMAIN, + domain=DOMAIN, data={}, + options={ + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SERVER_ID: "1", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: False, + }, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest"), patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", - return_value=True, - ) as forward_entry_setup: - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is config_entries.ConfigEntryState.LOADED - assert forward_entry_setup.mock_calls[0][1] == ( - entry, - "sensor", - ) + assert entry.state == ConfigEntryState.LOADED + assert hass.data[DOMAIN] + assert hass.services.has_service(DOMAIN, SPEED_TEST_SERVICE) -async def test_setup_failed(hass): +async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test SpeedTestDotNet failed due to an error.""" entry = MockConfigEntry( - domain=speedtestdotnet.DOMAIN, - data={}, + domain=DOMAIN, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest", side_effect=speedtest.ConfigRetrievalError): - - await hass.config_entries.async_setup(entry.entry_id) - - assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + mock_api.side_effect = speedtest.ConfigRetrievalError + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass): +async def test_unload_entry(hass: HomeAssistant) -> None: """Test removing SpeedTestDotNet.""" entry = MockConfigEntry( - domain=speedtestdotnet.DOMAIN, - data={}, + domain=DOMAIN, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest"): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert speedtestdotnet.DOMAIN not in hass.data + assert entry.state is ConfigEntryState.NOT_LOADED + assert DOMAIN not in hass.data + + +async def test_server_not_found(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test configured server id is not found.""" + + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN] + + mock_api.return_value.get_servers.side_effect = speedtest.NoMatchedServers + await hass.data[DOMAIN].async_refresh() + await hass.async_block_till_done() + state = hass.states.get("sensor.speedtest_ping") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_get_best_server_error(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test configured server id is not found.""" + + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN] + + mock_api.return_value.get_best_server.side_effect = ( + speedtest.SpeedtestBestServerFailure( + "Unable to connect to servers to test latency." + ) + ) + await hass.data[DOMAIN].async_refresh() + await hass.async_block_till_done() + state = hass.states.get("sensor.speedtest_ping") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index c08a9f3304f..11db05d2994 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -1,26 +1,28 @@ """Tests for SpeedTest sensors.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homeassistant.components import speedtestdotnet from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.speedtestdotnet.const import DEFAULT_NAME, SENSOR_TYPES +from homeassistant.core import HomeAssistant from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES from tests.common import MockConfigEntry -async def test_speedtestdotnet_sensors(hass): +async def test_speedtestdotnet_sensors( + hass: HomeAssistant, mock_api: MagicMock +) -> None: """Test sensors created for speedtestdotnet integration.""" entry = MockConfigEntry(domain=speedtestdotnet.DOMAIN, data={}) entry.add_to_hass(hass) - with patch("speedtest.Speedtest") as mock_api: - mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] - mock_api.return_value.results.dict.return_value = MOCK_RESULTS + mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] + mock_api.return_value.results.dict.return_value = MOCK_RESULTS - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 @@ -28,4 +30,5 @@ async def test_speedtestdotnet_sensors(hass): sensor = hass.states.get( f"sensor.{DEFAULT_NAME}_{SENSOR_TYPES[sensor_type][0]}" ) + assert sensor assert sensor.state == MOCK_STATES[sensor_type] From 80c535f02eb322e410f44b24917553d45499c767 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 12:26:50 +0200 Subject: [PATCH 094/112] Use NamedTuple - rova (#53292) Co-authored-by: Franck Nijhof --- homeassistant/components/rova/sensor.py | 48 ++++++++++++++++++------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 13f8fffb8d1..40dab258954 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -1,7 +1,9 @@ """Support for Rova garbage calendar.""" +from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import NamedTuple from requests.exceptions import ConnectTimeout, HTTPError from rova.rova import Rova @@ -24,13 +26,36 @@ CONF_HOUSE_NUMBER_SUFFIX = "house_number_suffix" UPDATE_DELAY = timedelta(hours=12) SCAN_INTERVAL = timedelta(hours=12) -# Supported sensor types: -# Key: [json_key, name, icon] -SENSOR_TYPES = { - "bio": ["gft", "Biowaste", "mdi:recycle"], - "paper": ["papier", "Paper", "mdi:recycle"], - "plastic": ["pmd", "PET", "mdi:recycle"], - "residual": ["restafval", "Residual", "mdi:recycle"], + +class RovaSensorMetadata(NamedTuple): + """Metadata for an individual rova sensor.""" + + name: str + json_key: str + icon: str + + +SENSOR_TYPES: dict[str, RovaSensorMetadata] = { + "bio": RovaSensorMetadata( + "Biowaste", + json_key="gft", + icon="mdi:recycle", + ), + "paper": RovaSensorMetadata( + "Paper", + json_key="papier", + icon="mdi:recycle", + ), + "plastic": RovaSensorMetadata( + "PET", + json_key="pmd", + icon="mdi:recycle", + ), + "residual": RovaSensorMetadata( + "Residual", + json_key="restafval", + icon="mdi:recycle", + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -90,18 +115,15 @@ class RovaSensor(SensorEntity): self._state = None - self._json_key = SENSOR_TYPES[self.sensor_key][0] + metadata = SENSOR_TYPES[sensor_key] + self._json_key = metadata.json_key + self._attr_icon = metadata.icon @property def name(self): """Return the name.""" return f"{self.platform_name}_{self.sensor_key}" - @property - def icon(self): - """Return the sensor icon.""" - return SENSOR_TYPES[self.sensor_key][2] - @property def device_class(self): """Return the class of this sensor.""" From 009f34bfed0378b378c0a9f8b60112fd6ca4438c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jul 2021 00:44:36 -1000 Subject: [PATCH 095/112] Add a homekit.unpair service to forcefully remove pairings (#53303) - Sometimes homekit will go unresponsive because a pairing for a specific device is missing. To avoid deleting the config entry and recreating it, which can be a painful process if there are many bridged entities, the homekit.unpair service allows forceful removal of the pairings so the accessory can be paired again. --- homeassistant/components/homekit/__init__.py | 65 ++++++++- homeassistant/components/homekit/const.py | 1 + .../components/homekit/services.yaml | 6 + tests/components/homekit/test_homekit.py | 124 +++++++++++++++++- 4 files changed, 193 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 5d9f2037610..a1203b25478 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -23,6 +23,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_IP_ADDRESS, CONF_NAME, @@ -34,11 +35,12 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import device_registry, entity_registry 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.loader import IntegrationNotFound, async_get_integration from . import ( # noqa: F401 @@ -93,6 +95,7 @@ from .const import ( MANUFACTURER, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, + SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) from .util import ( @@ -170,6 +173,12 @@ RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema( ) +UNPAIR_SERVICE_SCHEMA = vol.All( + vol.Schema(cv.ENTITY_SERVICE_FIELDS), + cv.has_at_least_one_key(ATTR_DEVICE_ID), +) + + def _async_get_entries_by_name(current_entries): """Return a dict of the entries by name.""" @@ -356,7 +365,7 @@ def _async_register_events_and_services(hass: HomeAssistant): hass.http.register_view(HomeKitPairingQRView) async def async_handle_homekit_reset_accessory(service): - """Handle start HomeKit service call.""" + """Handle reset accessory HomeKit service call.""" for entry_id in hass.data[DOMAIN]: if HOMEKIT not in hass.data[DOMAIN][entry_id]: continue @@ -378,6 +387,44 @@ def _async_register_events_and_services(hass: HomeAssistant): schema=RESET_ACCESSORY_SERVICE_SCHEMA, ) + async def async_handle_homekit_unpair(service): + """Handle unpair HomeKit service call.""" + referenced = await async_extract_referenced_entity_ids(hass, service) + dev_reg = device_registry.async_get(hass) + for device_id in referenced.referenced_devices: + dev_reg_ent = dev_reg.async_get(device_id) + if not dev_reg_ent: + raise HomeAssistantError(f"No device found for device id: {device_id}") + macs = [ + cval + for ctype, cval in dev_reg_ent.connections + if ctype == device_registry.CONNECTION_NETWORK_MAC + ] + domain_data = hass.data[DOMAIN] + matching_instances = [ + domain_data[entry_id][HOMEKIT] + for entry_id in domain_data + if HOMEKIT in domain_data[entry_id] + and domain_data[entry_id][HOMEKIT].driver + and device_registry.format_mac( + domain_data[entry_id][HOMEKIT].driver.state.mac + ) + in macs + ] + if not matching_instances: + raise HomeAssistantError( + f"No homekit accessory found for device id: {device_id}" + ) + for homekit in matching_instances: + homekit.async_unpair() + + hass.services.async_register( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + async_handle_homekit_unpair, + schema=UNPAIR_SERVICE_SCHEMA, + ) + async def async_handle_homekit_service_start(service): """Handle start HomeKit service call.""" tasks = [] @@ -639,7 +686,11 @@ class HomeKit: if self.driver.state.paired: return + self._async_show_setup_message() + @callback + def _async_show_setup_message(self): + """Show the pairing setup message.""" show_setup_message( self.hass, self._entry_id, @@ -648,6 +699,16 @@ class HomeKit: self.driver.accessory.xhm_uri(), ) + @callback + def async_unpair(self): + """Remove all pairings for an accessory so it can be repaired.""" + state = self.driver.state + for client_uuid in list(state.paired_clients): + state.remove_paired_client(client_uuid) + self.driver.async_persist() + self.driver.async_update_advertisement() + self._async_show_setup_message() + @callback def _async_register_bridge(self): """Register the bridge as a device so homekit_controller and exclude it from discovery.""" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 37788f9dca7..4fecd64b2b2 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -99,6 +99,7 @@ HOMEKIT_MODES = [HOMEKIT_MODE_BRIDGE, HOMEKIT_MODE_ACCESSORY] # #### HomeKit Component Services #### SERVICE_HOMEKIT_START = "start" SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" +SERVICE_HOMEKIT_UNPAIR = "unpair" # #### String Constants #### BRIDGE_MODEL = "Bridge" diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index 315a612241f..68e7804697b 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -14,3 +14,9 @@ reset_accessory: target: entity: {} +unpair: + name: Unpair an accessory or bridge + description: Forcefully remove all pairings from an accessory to allow re-pairing. Use this service if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost. + target: + device: + integration: homekit diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index ba34830f381..6539a7137d3 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -35,11 +35,13 @@ from homeassistant.components.homekit.const import ( HOMEKIT_MODE_BRIDGE, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, + SERVICE_HOMEKIT_UNPAIR, ) from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, @@ -52,7 +54,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_ON, ) -from homeassistant.core import State +from homeassistant.core import HomeAssistantError, State from homeassistant.helpers import device_registry from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, @@ -668,6 +670,126 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): homekit.status = STATUS_READY +async def test_homekit_unpair(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + formatted_mac = device_registry.format_mac(state.mac) + hk_bridge_dev = device_reg.async_get_device( + {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: hk_bridge_dev.id}, + blocking=True, + ) + await hass.async_block_till_done() + assert state.paired_clients == {} + homekit.status = STATUS_STOPPED + + +async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories with invalid device id.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: "notvalid"}, + blocking=True, + ) + await hass.async_block_till_done() + state.paired_clients = {"client1": "any"} + homekit.status = STATUS_STOPPED + + +async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories with a non-homekit device id.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + not_homekit_entry = MockConfigEntry( + domain="not_homekit", data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + device_entry = device_reg.async_get_or_create( + config_entry_id=not_homekit_entry.entry_id, + sw_version="0.16.0", + model="Powerwall 2", + manufacturer="Tesla", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: device_entry.id}, + blocking=True, + ) + await hass.async_block_till_done() + state.paired_clients = {"client1": "any"} + homekit.status = STATUS_STOPPED + + async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): """Test resetting HomeKit accessories with an unsupported entity.""" await async_setup_component(hass, "persistent_notification", {}) From ff781583fc268ac0aa5d2450de8c037795296668 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 22 Jul 2021 12:59:39 +0200 Subject: [PATCH 096/112] Remove energy attributes from switch platform in devolo Home Control (#53335) --- .../components/devolo_home_control/switch.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index dcfa22db692..c9dabf23c39 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -54,25 +54,12 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): self._unique_id ) self._is_on: bool = self._binary_switch_property.state - self._consumption: float | None - - if hasattr(self._device_instance, "consumption_property"): - self._consumption = self._device_instance.consumption_property.get( - self._unique_id.replace("BinarySwitch", "Meter") - ).current - else: - self._consumption = None @property def is_on(self) -> bool: """Return the state.""" return self._is_on - @property - def current_power_w(self) -> float | None: - """Return the current consumption.""" - return self._consumption - def turn_on(self, **kwargs: Any) -> None: """Switch on the device.""" self._is_on = True @@ -87,10 +74,6 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): """Update the binary switch state and consumption.""" if message[0].startswith("devolo.BinarySwitch"): self._is_on = self._device_instance.binary_switch_property[message[0]].state - elif message[0].startswith("devolo.Meter"): - self._consumption = self._device_instance.consumption_property[ - message[0] - ].current else: self._generic_message(message) self.schedule_update_ha_state() From f009b1442ff67112430486a7e88104af9cb4da2c Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 22 Jul 2021 14:40:39 +0300 Subject: [PATCH 097/112] Switch wirelesstag to use cloud push (#50984) --- CODEOWNERS | 1 + .../components/wirelesstag/__init__.py | 138 +++++------------- .../components/wirelesstag/binary_sensor.py | 7 +- .../components/wirelesstag/manifest.json | 6 +- .../components/wirelesstag/sensor.py | 8 +- requirements_all.txt | 2 +- 6 files changed, 49 insertions(+), 113 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 28dc16e7342..249a00a796e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -565,6 +565,7 @@ homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @esev homeassistant/components/wiffi/* @mampfes homeassistant/components/wilight/* @leofig-rj +homeassistant/components/wirelesstag/* @sergeymaysak homeassistant/components/withings/* @vangorra homeassistant/components/wled/* @frenck homeassistant/components/wolflink/* @adamkrol93 diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 4e3ace38411..519663d1261 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -3,9 +3,8 @@ import logging from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from wirelesstagpy import NotificationConfig as NC, WirelessTags, WirelessTagsException +from wirelesstagpy import WirelessTags, WirelessTagsException -from homeassistant import util from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, @@ -67,11 +66,6 @@ class WirelessTagPlatform: self.tags = {} self._local_base_url = None - @property - def tag_manager_macs(self): - """Return list of tag managers mac addresses in user account.""" - return self.api.mac_addresses - def load_tags(self): """Load tags from remote server.""" self.tags = self.api.load_tags() @@ -91,97 +85,44 @@ class WirelessTagPlatform: if disarm_func is not None: disarm_func(switch.tag_id, switch.tag_manager_mac) - def make_notifications(self, binary_sensors, mac): - """Create configurations for push notifications.""" - _LOGGER.info("Creating configurations for push notifications") - configs = [] + def start_monitoring(self): + """Start monitoring push events.""" - bi_url = self.binary_event_callback_url - for bi_sensor in binary_sensors: - configs.extend(bi_sensor.event.build_notifications(bi_url, mac)) - - update_url = self.update_callback_url - - update_config = NC.make_config_for_update_event(update_url, mac) - - configs.append(update_config) - return configs - - def install_push_notifications(self, binary_sensors): - """Register local push notification from tag manager.""" - _LOGGER.info("Registering local push notifications") - for mac in self.tag_manager_macs: - configs = self.make_notifications(binary_sensors, mac) - # install notifications for all tags in tag manager - # specified by mac - result = self.api.install_push_notification(0, configs, True, mac) - if not result: - self.hass.components.persistent_notification.create( - "Error: failed to install local push notifications
", - title="Wireless Sensor Tag Setup Local Push Notifications", - notification_id="wirelesstag_failed_push_notification", - ) - else: - _LOGGER.info( - "Installed push notifications for all tags in %s", - mac, - ) - - @property - def local_base_url(self): - """Define base url of hass in local network.""" - if self._local_base_url is None: - self._local_base_url = f"http://{util.get_local_ip()}" - - port = self.hass.config.api.port - if port is not None: - self._local_base_url += f":{port}" - return self._local_base_url - - @property - def update_callback_url(self): - """Return url for local push notifications(update event).""" - return f"{self.local_base_url}/api/events/wirelesstag_update_tags" - - @property - def binary_event_callback_url(self): - """Return url for local push notifications(binary event).""" - return f"{self.local_base_url}/api/events/wirelesstag_binary_event" - - def handle_update_tags_event(self, event): - """Handle push event from wireless tag manager.""" - _LOGGER.info("Push notification for update arrived: %s", event) - try: - tag_id = event.data.get("id") - mac = event.data.get("mac") - dispatcher_send(self.hass, SIGNAL_TAG_UPDATE.format(tag_id, mac), event) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.error( - "Unable to handle tag update event:\ - %s error: %s", - str(event), - str(ex), + def push_callback(tags_spec, event_spec): + """Handle push update.""" + _LOGGER.debug( + "Push notification arrived: %s, events: %s", tags_spec, event_spec ) + for uuid, tag in tags_spec.items(): + try: + tag_id = tag.tag_id + mac = tag.tag_manager_mac + _LOGGER.debug("Push notification for tag update arrived: %s", tag) + dispatcher_send( + self.hass, SIGNAL_TAG_UPDATE.format(tag_id, mac), tag + ) + if uuid in event_spec: + events = event_spec[uuid] + for event in events: + _LOGGER.debug( + "Push notification for binary event arrived: %s", event + ) + dispatcher_send( + self.hass, + SIGNAL_BINARY_EVENT_UPDATE.format( + tag_id, event.type, mac + ), + tag, + ) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error( + "Unable to handle tag update:\ + %s error: %s", + str(tag), + str(ex), + ) - def handle_binary_event(self, event): - """Handle push notifications for binary (on/off) events.""" - _LOGGER.info("Push notification for binary event arrived: %s", event) - try: - tag_id = event.data.get("id") - event_type = event.data.get("type") - mac = event.data.get("mac") - dispatcher_send( - self.hass, - SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac), - event, - ) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.error( - "Unable to handle tag binary event:\ - %s error: %s", - str(event), - str(ex), - ) + self.api.start_monitoring(push_callback) def setup(hass, config): @@ -195,6 +136,7 @@ def setup(hass, config): platform = WirelessTagPlatform(hass, wirelesstags) platform.load_tags() + platform.start_monitoring() hass.data[DOMAIN] = platform except (ConnectTimeout, HTTPError, WirelessTagsException) as ex: _LOGGER.error("Unable to connect to wirelesstag.net service: %s", str(ex)) @@ -205,12 +147,6 @@ def setup(hass, config): ) return False - # listen to custom events - hass.bus.listen( - "wirelesstag_update_tags", hass.data[DOMAIN].handle_update_tags_event - ) - hass.bus.listen("wirelesstag_binary_event", hass.data[DOMAIN].handle_binary_event) - return True diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index ef97867e829..da901f31cd6 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -81,7 +81,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) add_entities(sensors, True) - hass.add_job(platform.install_push_notifications, sensors) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -134,8 +133,8 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): return self.principal_value @callback - def _on_binary_event_callback(self, event): + def _on_binary_event_callback(self, new_tag): """Update state from arrived push notification.""" - # state should be 'on' or 'off' - self._state = event.data.get("state") + self._tag = new_tag + self._state = self.updated_state_value() self.async_write_ha_state() diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json index fd18235c994..37c1b82cba9 100644 --- a/homeassistant/components/wirelesstag/manifest.json +++ b/homeassistant/components/wirelesstag/manifest.json @@ -2,7 +2,7 @@ "domain": "wirelesstag", "name": "Wireless Sensor Tags", "documentation": "https://www.home-assistant.io/integrations/wirelesstag", - "requirements": ["wirelesstagpy==0.4.1"], - "codeowners": [], - "iot_class": "local_push" + "requirements": ["wirelesstagpy==0.5.0"], + "codeowners": ["@sergeymaysak"], + "iot_class": "cloud_push" } diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index cc0ce0cb888..de70efda424 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -108,9 +108,9 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): return self._tag.sensor[self._sensor_type] @callback - def _update_tag_info_callback(self, event): + def _update_tag_info_callback(self, new_tag): """Handle push notification sent by tag manager.""" - _LOGGER.debug("Entity to update state: %s event data: %s", self, event.data) - new_value = self._sensor.value_from_update_event(event.data) - self._state = self.decorate_value(new_value) + _LOGGER.debug("Entity to update state: %s with new tag: %s", self, new_tag) + self._tag = new_tag + self._state = self.updated_state_value() self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 32223f0fdfb..d904a1187b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2372,7 +2372,7 @@ webexteamssdk==1.1.1 wiffi==1.0.1 # homeassistant.components.wirelesstag -wirelesstagpy==0.4.1 +wirelesstagpy==0.5.0 # homeassistant.components.withings withings-api==2.3.2 From d3e77e00e14267ee5869cd198fbbdfc2344f51cd Mon Sep 17 00:00:00 2001 From: sillyfrog <816454+sillyfrog@users.noreply.github.com> Date: Thu, 22 Jul 2021 22:40:33 +1000 Subject: [PATCH 098/112] Add Automate Pulse Hub v2 support (#39501) Co-authored-by: Franck Nijhof Co-authored-by: Sillyfrog --- .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/en.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 ++++++++ 17 files changed, 589 insertions(+) create mode 100644 homeassistant/components/automate/__init__.py create mode 100644 homeassistant/components/automate/base.py create mode 100644 homeassistant/components/automate/config_flow.py create mode 100644 homeassistant/components/automate/const.py create mode 100644 homeassistant/components/automate/cover.py create mode 100644 homeassistant/components/automate/helpers.py create mode 100644 homeassistant/components/automate/hub.py create mode 100644 homeassistant/components/automate/manifest.json create mode 100644 homeassistant/components/automate/strings.json create mode 100644 homeassistant/components/automate/translations/en.json create mode 100644 tests/components/automate/__init__.py create mode 100644 tests/components/automate/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2693080986b..4be3d2e5f01 100644 --- a/.coveragerc +++ b/.coveragerc @@ -75,6 +75,12 @@ 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 249a00a796e..0e92885d247 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,7 @@ 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 new file mode 100644 index 00000000000..c4f34d96a05 --- /dev/null +++ b/homeassistant/components/automate/__init__.py @@ -0,0 +1,36 @@ +"""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 new file mode 100644 index 00000000000..de37933e54d --- /dev/null +++ b/homeassistant/components/automate/base.py @@ -0,0 +1,93 @@ +"""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 new file mode 100644 index 00000000000..45d3a5b9349 --- /dev/null +++ b/homeassistant/components/automate/config_flow.py @@ -0,0 +1,37 @@ +"""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 new file mode 100644 index 00000000000..0c1dc1bd2e5 --- /dev/null +++ b/homeassistant/components/automate/const.py @@ -0,0 +1,6 @@ +"""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 new file mode 100644 index 00000000000..86dcda10adf --- /dev/null +++ b/homeassistant/components/automate/cover.py @@ -0,0 +1,147 @@ +"""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 new file mode 100644 index 00000000000..92130eeb79b --- /dev/null +++ b/homeassistant/components/automate/helpers.py @@ -0,0 +1,46 @@ +"""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 new file mode 100644 index 00000000000..78e1b5873fa --- /dev/null +++ b/homeassistant/components/automate/hub.py @@ -0,0 +1,89 @@ +"""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 new file mode 100644 index 00000000000..071aaf1589f --- /dev/null +++ b/homeassistant/components/automate/manifest.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 00000000000..8a8131f0f67 --- /dev/null +++ b/homeassistant/components/automate/strings.json @@ -0,0 +1,19 @@ +{ + "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/en.json b/homeassistant/components/automate/translations/en.json new file mode 100644 index 00000000000..2ad35962b25 --- /dev/null +++ b/homeassistant/components/automate/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": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b88d5639783..0e7b6c52cc2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -28,6 +28,7 @@ FLOWS = [ "atag", "august", "aurora", + "automate", "awair", "axis", "azure_devops", diff --git a/requirements_all.txt b/requirements_all.txt index d904a1187b3..a43f754b202 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -220,6 +220,9 @@ aionotify==0.2.0 # homeassistant.components.notion aionotion==1.1.0 +# 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 2baf16de687..5732833e079 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -142,6 +142,9 @@ aiomusiccast==0.8.0 # homeassistant.components.notion aionotion==1.1.0 +# 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 new file mode 100644 index 00000000000..6a87ba942e3 --- /dev/null +++ b/tests/components/automate/__init__.py @@ -0,0 +1 @@ +"""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 new file mode 100644 index 00000000000..fea2fa995cd --- /dev/null +++ b/tests/components/automate/test_config_flow.py @@ -0,0 +1,69 @@ +"""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 d371ab9deb9d20135bd74a37110f585f791c13c3 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 22 Jul 2021 08:47:30 -0400 Subject: [PATCH 099/112] Use entity class attributes for caldav (#53332) --- homeassistant/components/caldav/calendar.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 61186249c51..d27555beb2c 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -119,24 +119,13 @@ class WebDavCalendarEventDevice(CalendarEventDevice): self.data = WebDavCalendarData(calendar, days, all_day, search) self.entity_id = entity_id self._event = None - self._name = name - self._offset_reached = False - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return {"offset_reached": self._offset_reached} + self._attr_name = name @property def event(self): """Return the next upcoming event.""" return self._event - @property - def name(self): - """Return the name of the entity.""" - return self._name - async def async_get_events(self, hass, start_date, end_date): """Get all events in a specific time frame.""" return await self.data.async_get_events(hass, start_date, end_date) @@ -149,8 +138,8 @@ class WebDavCalendarEventDevice(CalendarEventDevice): self._event = event return event = calculate_offset(event, OFFSET) - self._offset_reached = is_offset_reached(event) self._event = event + self._attr_extra_state_attributes = {"offset_reached": is_offset_reached(event)} class WebDavCalendarData: From f778467d631b8eca9499c776862e6a178317aa2c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jul 2021 15:29:50 +0200 Subject: [PATCH 100/112] Use NamedTuple - rainbird (#53329) * Use NamedTuple - rainbird * Apply suggestions from code review Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/rainbird/__init__.py | 26 +++++++++++-- .../components/rainbird/binary_sensor.py | 31 +++++++-------- homeassistant/components/rainbird/sensor.py | 38 +++++++++---------- 3 files changed, 55 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index d8334470b60..55ed421bd24 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,5 +1,8 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" +from __future__ import annotations + import logging +from typing import NamedTuple from pyrainbird import RainbirdController import voluptuous as vol @@ -26,10 +29,25 @@ DOMAIN = "rainbird" SENSOR_TYPE_RAINDELAY = "raindelay" SENSOR_TYPE_RAINSENSOR = "rainsensor" -# sensor_type [ description, unit, icon ] -SENSOR_TYPES = { - SENSOR_TYPE_RAINSENSOR: ["Rainsensor", None, "mdi:water"], - SENSOR_TYPE_RAINDELAY: ["Raindelay", None, "mdi:water-off"], + + +class RainBirdSensorMetadata(NamedTuple): + """Metadata for an individual RainBird sensor.""" + + name: str + icon: str + unit_of_measurement: str | None = None + + +SENSOR_TYPES: dict[str, RainBirdSensorMetadata] = { + SENSOR_TYPE_RAINSENSOR: RainBirdSensorMetadata( + "Rainsensor", + icon="mdi:water", + ), + SENSOR_TYPE_RAINDELAY: RainBirdSensorMetadata( + "Raindelay", + icon="mdi:water-off", + ), } TRIGGER_TIME_SCHEMA = vol.All( diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 62c6824f5e0..9960d7670b2 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -11,6 +11,7 @@ from . import ( SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR, SENSOR_TYPES, + RainBirdSensorMetadata, ) _LOGGER = logging.getLogger(__name__) @@ -23,19 +24,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): controller = hass.data[DATA_RAINBIRD][discovery_info[RAINBIRD_CONTROLLER]] add_entities( - [RainBirdSensor(controller, sensor_type) for sensor_type in SENSOR_TYPES], True + [ + RainBirdSensor(controller, sensor_type, metadata) + for sensor_type, metadata in SENSOR_TYPES.items() + ], + True, ) class RainBirdSensor(BinarySensorEntity): """A sensor implementation for Rain Bird device.""" - def __init__(self, controller: RainbirdController, sensor_type): + def __init__( + self, + controller: RainbirdController, + sensor_type, + metadata: RainBirdSensorMetadata, + ): """Initialize the Rain Bird sensor.""" self._sensor_type = sensor_type self._controller = controller - self._name = SENSOR_TYPES[self._sensor_type][0] - self._icon = SENSOR_TYPES[self._sensor_type][2] + + self._attr_name = metadata.name + self._attr_icon = metadata.icon self._state = None @property @@ -45,20 +56,10 @@ class RainBirdSensor(BinarySensorEntity): 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) state = None if self._sensor_type == SENSOR_TYPE_RAINSENSOR: state = self._controller.get_rain_sensor_state() elif self._sensor_type == SENSOR_TYPE_RAINDELAY: state = self._controller.get_rain_delay() self._state = None if state is None else bool(state) - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def icon(self): - """Return icon.""" - return self._icon diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 2c542dc12a9..36c3d50e1c1 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -11,6 +11,7 @@ from . import ( SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR, SENSOR_TYPES, + RainBirdSensorMetadata, ) _LOGGER = logging.getLogger(__name__) @@ -24,20 +25,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): controller = hass.data[DATA_RAINBIRD][discovery_info[RAINBIRD_CONTROLLER]] add_entities( - [RainBirdSensor(controller, sensor_type) for sensor_type in SENSOR_TYPES], True + [ + RainBirdSensor(controller, sensor_type, metadata) + for sensor_type, metadata in SENSOR_TYPES.items() + ], + True, ) class RainBirdSensor(SensorEntity): """A sensor implementation for Rain Bird device.""" - def __init__(self, controller: RainbirdController, sensor_type): + def __init__( + self, + controller: RainbirdController, + sensor_type, + metadata: RainBirdSensorMetadata, + ): """Initialize the Rain Bird sensor.""" self._sensor_type = sensor_type self._controller = controller - self._name = SENSOR_TYPES[self._sensor_type][0] - self._icon = SENSOR_TYPES[self._sensor_type][2] - self._unit_of_measurement = SENSOR_TYPES[self._sensor_type][1] + + self._attr_name = metadata.name + self._attr_icon = metadata.icon + self._attr_unit_of_measurement = metadata.unit_of_measurement self._state = None @property @@ -47,23 +58,8 @@ class RainBirdSensor(SensorEntity): 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) if self._sensor_type == SENSOR_TYPE_RAINSENSOR: self._state = self._controller.get_rain_sensor_state() elif self._sensor_type == SENSOR_TYPE_RAINDELAY: self._state = self._controller.get_rain_delay() - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return icon.""" - return self._icon From 258162d9331087b618f0d2a7dba4ede1ef74aabc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 22 Jul 2021 16:35:19 +0200 Subject: [PATCH 101/112] Upgrade wled to 0.7.3 (#53340) --- homeassistant/components/wled/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 348109f6b87..dbe13fe56ca 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -3,7 +3,7 @@ "name": "WLED", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wled", - "requirements": ["wled==0.7.1"], + "requirements": ["wled==0.7.3"], "zeroconf": ["_wled._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index a43f754b202..d6916cc0122 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2381,7 +2381,7 @@ wirelesstagpy==0.5.0 withings-api==2.3.2 # homeassistant.components.wled -wled==0.7.1 +wled==0.7.3 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5732833e079..8e8fa192141 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1305,7 +1305,7 @@ wiffi==1.0.1 withings-api==2.3.2 # homeassistant.components.wled -wled==0.7.1 +wled==0.7.3 # homeassistant.components.wolflink wolf_smartset==0.1.11 From 24e07bc1545ac69c23e93b164fc6be0e0d1a681b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 22 Jul 2021 18:19:39 +0200 Subject: [PATCH 102/112] Fritzbox enable temp sensor (#52558) --- homeassistant/components/fritzbox/__init__.py | 19 ++++++ .../components/fritzbox/binary_sensor.py | 2 + homeassistant/components/fritzbox/climate.py | 2 + homeassistant/components/fritzbox/model.py | 1 + homeassistant/components/fritzbox/sensor.py | 18 +++--- homeassistant/components/fritzbox/switch.py | 2 + tests/components/fritzbox/__init__.py | 57 +++++++----------- tests/components/fritzbox/const.py | 20 +++++++ .../components/fritzbox/test_binary_sensor.py | 18 +++++- tests/components/fritzbox/test_climate.py | 18 +++++- tests/components/fritzbox/test_config_flow.py | 6 +- tests/components/fritzbox/test_init.py | 60 ++++++++++++++++++- tests/components/fritzbox/test_sensor.py | 20 +++++-- tests/components/fritzbox/test_switch.py | 25 ++++++-- 14 files changed, 205 insertions(+), 63 deletions(-) create mode 100644 tests/components/fritzbox/const.py diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 124719b93c1..087faeb2be9 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -6,6 +6,7 @@ from datetime import timedelta from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError import requests +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -16,10 +17,12 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + TEMP_CELSIUS, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -81,6 +84,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + def _update_unique_id(entry: RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if ( + entry.unit_of_measurement == TEMP_CELSIUS + and "_temperature" not in entry.unique_id + ): + new_unique_id = f"{entry.unique_id}_temperature" + LOGGER.info( + "Migrating unique_id [%s] to [%s]", entry.unique_id, new_unique_id + ) + return {"new_unique_id": new_unique_id} + return None + + await async_migrate_entries(hass, entry.entry_id, _update_unique_id) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) def logout_fritzbox(event: Event) -> None: @@ -123,6 +141,7 @@ class FritzBoxEntity(CoordinatorEntity): 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] @property def device(self) -> FritzhomeDevice: diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 242e3d6e644..f6dbaed97cf 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_WINDOW, BinarySensorEntity, ) +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -37,6 +38,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}", ATTR_UNIT_OF_MEASUREMENT: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_WINDOW, + ATTR_STATE_CLASS: None, }, coordinator, ain, diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index c50e0d4f270..0551c5e0455 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -13,6 +13,7 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -74,6 +75,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}", ATTR_UNIT_OF_MEASUREMENT: None, ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: None, }, coordinator, ain, diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 1cde7b9ca70..0e401a75be3 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -11,6 +11,7 @@ class EntityInfo(TypedDict): entity_id: str unit_of_measurement: str | None device_class: str | None + state_class: str | None class ClimateExtraAttributes(TypedDict, total=False): diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index db50776d69c..0a83e3ba60c 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,7 +1,11 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -34,18 +38,15 @@ async def async_setup_entry( coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] for ain, device in coordinator.data.items(): - if ( - device.has_temperature_sensor - and not device.has_switch - and not device.has_thermostat - ): + if device.has_temperature_sensor and not device.has_thermostat: entities.append( FritzBoxTempSensor( { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", + ATTR_NAME: f"{device.name} Temperature", + ATTR_ENTITY_ID: f"{device.ain}_temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, coordinator, ain, @@ -60,6 +61,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}_battery", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_STATE_CLASS: None, }, coordinator, ain, diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 82581473714..22b2adf5800 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -50,6 +51,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}", ATTR_UNIT_OF_MEASUREMENT: None, ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: None, }, coordinator, ain, diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index ee5d15bd1b8..3ff4b71364e 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -5,22 +5,16 @@ from typing import Any from unittest.mock import Mock from homeassistant.components.fritzbox.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from .const import ( + CONF_FAKE_AIN, + CONF_FAKE_MANUFACTURER, + CONF_FAKE_NAME, + CONF_FAKE_PRODUCTNAME, +) -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PASSWORD: "fake_pass", - CONF_USERNAME: "fake_user", - } - ] - } -} +from tests.common import MockConfigEntry async def setup_config_entry( @@ -45,27 +39,32 @@ async def setup_config_entry( return result -class FritzDeviceBinarySensorMock(Mock): +class FritzDeviceBaseMock(Mock): + """base mock of a AVM Fritz!Box binary sensor device.""" + + ain = CONF_FAKE_AIN + manufacturer = CONF_FAKE_MANUFACTURER + name = CONF_FAKE_NAME + productname = CONF_FAKE_PRODUCTNAME + + +class FritzDeviceBinarySensorMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box binary sensor device.""" - ain = "fake_ain" alert_state = "fake_state" + battery_level = 23 fw_version = "1.2.3" has_alarm = True has_switch = False has_temperature_sensor = False has_thermostat = False - manufacturer = "fake_manufacturer" - name = "fake_name" present = True - productname = "fake_productname" -class FritzDeviceClimateMock(Mock): +class FritzDeviceClimateMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box climate device.""" actual_temperature = 18.0 - ain = "fake_ain" alert_state = "fake_state" battery_level = 23 battery_low = True @@ -79,19 +78,15 @@ class FritzDeviceClimateMock(Mock): has_thermostat = True holiday_active = "fake_holiday" lock = "fake_locked" - manufacturer = "fake_manufacturer" - name = "fake_name" present = True - productname = "fake_productname" summer_active = "fake_summer" target_temperature = 19.5 window_open = "fake_window" -class FritzDeviceSensorMock(Mock): +class FritzDeviceSensorMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box sensor device.""" - ain = "fake_ain" battery_level = 23 device_lock = "fake_locked_device" fw_version = "1.2.3" @@ -100,17 +95,14 @@ class FritzDeviceSensorMock(Mock): has_temperature_sensor = True has_thermostat = False lock = "fake_locked" - manufacturer = "fake_manufacturer" - name = "fake_name" present = True - productname = "fake_productname" temperature = 1.23 -class FritzDeviceSwitchMock(Mock): +class FritzDeviceSwitchMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box switch device.""" - ain = "fake_ain" + battery_level = None device_lock = "fake_locked_device" energy = 1234 fw_version = "1.2.3" @@ -120,9 +112,6 @@ class FritzDeviceSwitchMock(Mock): has_thermostat = False switch_state = "fake_state" lock = "fake_locked" - manufacturer = "fake_manufacturer" - name = "fake_name" power = 5678 present = True - productname = "fake_productname" - temperature = 135 + temperature = 1.23 diff --git a/tests/components/fritzbox/const.py b/tests/components/fritzbox/const.py new file mode 100644 index 00000000000..1b8bc927800 --- /dev/null +++ b/tests/components/fritzbox/const.py @@ -0,0 +1,20 @@ +"""Constants for fritzbox tests.""" +from homeassistant.components.fritzbox.const import DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_PASSWORD: "fake_pass", + CONF_USERNAME: "fake_user", + } + ] + } +} + +CONF_FAKE_NAME = "fake_name" +CONF_FAKE_AIN = "fake_ain" +CONF_FAKE_MANUFACTURER = "fake_manufacturer" +CONF_FAKE_PRODUCTNAME = "fake_productname" diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 7a2d2347004..f4e32fbe3df 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -7,21 +7,25 @@ from requests.exceptions import HTTPError from homeassistant.components.binary_sensor import DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, + PERCENTAGE, STATE_OFF, STATE_ON, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceBinarySensorMock, setup_config_entry +from . import FritzDeviceBinarySensorMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -34,8 +38,16 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): state = hass.states.get(ENTITY_ID) assert state assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME assert state.attributes[ATTR_DEVICE_CLASS] == "window" + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") + assert state + assert state.state == "23" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_STATE_CLASS not in state.attributes async def test_is_off(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 59d32e18c34..30ee7130fea 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -30,21 +30,25 @@ from homeassistant.components.fritzbox.const import ( ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, + PERCENTAGE, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceClimateMock, setup_config_entry +from . import FritzDeviceClimateMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -58,7 +62,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state assert state.attributes[ATTR_BATTERY_LEVEL] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME assert state.attributes[ATTR_HVAC_MODES] == [HVAC_MODE_HEAT, HVAC_MODE_OFF] assert state.attributes[ATTR_MAX_TEMP] == 28 assert state.attributes[ATTR_MIN_TEMP] == 8 @@ -71,8 +75,16 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_STATE_SUMMER_MODE] == "fake_summer" assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" assert state.attributes[ATTR_TEMPERATURE] == 19.5 + assert ATTR_STATE_CLASS not in state.attributes assert state.state == HVAC_MODE_HEAT + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") + assert state + assert state.state == "23" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_STATE_CLASS not in state.attributes + async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock): """Test turn device on.""" diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index a9de92060ec..6d62122a871 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -21,14 +21,14 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_FORM, ) -from . import MOCK_CONFIG +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_SSDP_DATA = { ATTR_SSDP_LOCATION: "https://fake_host:12345/test", - ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, ATTR_UPNP_UDN: "uuid:only-a-test", } @@ -192,7 +192,7 @@ async def test_ssdp(hass: HomeAssistant, fritz: Mock): user_input={CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user"}, ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "fake_name" + assert result["title"] == CONF_FAKE_NAME assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" assert result["data"][CONF_USERNAME] == "fake_user" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 438335868cd..ea0356c6af1 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -7,6 +7,7 @@ from pyfritzhome import LoginError from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -15,10 +16,13 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from . import MOCK_CONFIG, FritzDeviceSwitchMock, setup_config_entry +from . import FritzDeviceSwitchMock, setup_config_entry +from .const import CONF_FAKE_AIN, CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry @@ -38,6 +42,58 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): ] +async def test_update_unique_id(hass: HomeAssistant, fritz: Mock): + """Test unique_id update of integration.""" + entry = MockConfigEntry( + domain=FB_DOMAIN, + data=MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + FB_DOMAIN, + CONF_FAKE_AIN, + unit_of_measurement=TEMP_CELSIUS, + config_entry=entry, + ) + assert entity.unique_id == CONF_FAKE_AIN + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{CONF_FAKE_AIN}_temperature" + + +async def test_update_unique_id_no_change(hass: HomeAssistant, fritz: Mock): + """Test unique_id is not updated of integration.""" + entry = MockConfigEntry( + domain=FB_DOMAIN, + data=MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + FB_DOMAIN, + f"{CONF_FAKE_AIN}_temperature", + unit_of_measurement=TEMP_CELSIUS, + config_entry=entry, + ) + assert entity.unique_id == f"{CONF_FAKE_AIN}_temperature" + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{CONF_FAKE_AIN}_temperature" + + async def test_coordinator_update_after_reboot(hass: HomeAssistant, fritz: Mock): """Test coordinator after reboot.""" entry = MockConfigEntry( @@ -74,7 +130,7 @@ async def test_coordinator_update_after_password_change( async def test_unload_remove(hass: HomeAssistant, fritz: Mock): """Test unload and remove of integration.""" fritz().get_devices.return_value = [FritzDeviceSwitchMock()] - entity_id = f"{SWITCH_DOMAIN}.fake_name" + entity_id = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" entry = MockConfigEntry( domain=FB_DOMAIN, diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index c1d82a93189..664b6765c03 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -9,7 +9,11 @@ from homeassistant.components.fritzbox.const import ( ATTR_STATE_LOCKED, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN, + STATE_CLASS_MEASUREMENT, +) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, @@ -20,11 +24,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceSensorMock, setup_config_entry +from . import FritzDeviceSensorMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -33,20 +38,23 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(f"{ENTITY_ID}_temperature") assert state assert state.state == "1.23" - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT state = hass.states.get(f"{ENTITY_ID}_battery") assert state assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name Battery" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_STATE_CLASS not in state.attributes async def test_update(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index cc0caeafa69..4bace3834fb 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -12,11 +12,17 @@ from homeassistant.components.fritzbox.const import ( ATTR_TOTAL_CONSUMPTION_UNIT, DOMAIN as FB_DOMAIN, ) +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, +) from homeassistant.components.switch import ATTR_CURRENT_POWER_W, DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, ENERGY_KILO_WATT_HOUR, SERVICE_TURN_OFF, @@ -27,11 +33,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceSwitchMock, setup_config_entry +from . import FritzDeviceSwitchMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -45,13 +52,23 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state assert state.state == STATE_ON assert state.attributes[ATTR_CURRENT_POWER_W] == 5.678 - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" - assert state.attributes[ATTR_TEMPERATURE] == "135" + assert state.attributes[ATTR_TEMPERATURE] == "1.23" assert state.attributes[ATTR_TEMPERATURE_UNIT] == TEMP_CELSIUS assert state.attributes[ATTR_TOTAL_CONSUMPTION] == "1.234" assert state.attributes[ATTR_TOTAL_CONSUMPTION_UNIT] == ENERGY_KILO_WATT_HOUR + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature") + assert state + assert state.state == "1.23" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" + assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" + assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT async def test_turn_on(hass: HomeAssistant, fritz: Mock): From c9c1c62d67929897de7897db6918887da3768fba Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 22 Jul 2021 18:24:06 +0200 Subject: [PATCH 103/112] Add state class and last reset to consumption sensor in devolo Home Control (#53337) * Add state class and last reset * Use STATE_CLASS_MEASUREMENT --- .../components/devolo_home_control/sensor.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 0500fc72b0b..af67c6cd78a 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -167,6 +168,12 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): self._sensor_type = consumption self._device_class = DEVICE_CLASS_MAPPING.get(consumption) + if consumption == "total": + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_last_reset = device_instance.consumption_property[ + element_uid + ].total_since + self._value = getattr( device_instance.consumption_property[element_uid], consumption ) @@ -183,11 +190,15 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): def _sync(self, message: tuple) -> None: """Update the consumption sensor state.""" - if message[0] == self._unique_id: + if message[0] == self._unique_id and message[2] != "total_since": self._value = getattr( self._device_instance.consumption_property[self._unique_id], self._sensor_type, ) + elif message[0] == self._unique_id and message[2] == "total_since": + self._attr_last_reset = self._device_instance.consumption_property[ + self._unique_id + ].total_since else: self._generic_message(message) self.schedule_update_ha_state() From 74023fce21159623339dda7c18fd3ab72a6dfba5 Mon Sep 17 00:00:00 2001 From: Ian Harcombe Date: Thu, 22 Jul 2021 17:24:47 +0100 Subject: [PATCH 104/112] Fix for issue #53031 (#53343) Logs from issue #53031 show that not only ints are appearing in the values for the forecast data now, so change the check from just for int, to see whether the value has a "value" attribute before dereferencing it. --- homeassistant/components/metoffice/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 1307c3aae45..1120e75c50a 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -221,7 +221,7 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): elif hasattr(self.coordinator.data.now, self._type): value = getattr(self.coordinator.data.now, self._type) - if not isinstance(value, int): + if hasattr(value, "value"): value = value.value return value From b2528e97b603c71cc05eb0a9dc50f6f8d67148f7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 22 Jul 2021 18:30:54 +0200 Subject: [PATCH 105/112] Making Pytest default for VS code (#53203) --- .vscode/tasks.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1308f535428..24d643b96bc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,10 +5,7 @@ "label": "Run Home Assistant Core", "type": "shell", "command": "hass -c ./config", - "group": { - "kind": "test", - "isDefault": true - }, + "group": "test", "presentation": { "reveal": "always", "panel": "new" @@ -19,7 +16,9 @@ "label": "Pytest", "type": "shell", "command": "pytest --timeout=10 tests", - "dependsOn": ["Install all Test Requirements"], + "dependsOn": [ + "Install all Test Requirements" + ], "group": { "kind": "test", "isDefault": true @@ -48,7 +47,9 @@ "label": "Pylint", "type": "shell", "command": "pylint homeassistant", - "dependsOn": ["Install all Requirements"], + "dependsOn": [ + "Install all Requirements" + ], "group": { "kind": "test", "isDefault": true From 0707792bec7169f95a6aee9d7d11a435fa4cd20b Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 22 Jul 2021 13:04:02 -0500 Subject: [PATCH 106/112] Handle more Sonos snapshot restore scenarios (#53277) --- homeassistant/components/sonos/speaker.py | 65 ++++++++++++++++++----- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 3ff6627bb8a..d7bc1269ea1 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -14,7 +14,7 @@ import async_timeout from pysonos.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo from pysonos.data_structures import DidlAudioBroadcast, DidlPlaylistContainer from pysonos.events_base import Event as SonosEvent, SubscriptionBase -from pysonos.exceptions import SoCoException +from pysonos.exceptions import SoCoException, SoCoUPnPException from pysonos.music_library import MusicLibrary from pysonos.plugins.sharelink import ShareLinkPlugin from pysonos.snapshot import Snapshot @@ -25,6 +25,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as ent_reg from homeassistant.helpers.dispatcher import ( async_dispatcher_send, @@ -802,27 +803,58 @@ class SonosSpeaker: """Restore snapshots for all the speakers.""" def _restore_groups( - speakers: list[SonosSpeaker], with_group: bool + speakers: set[SonosSpeaker], with_group: bool ) -> list[list[SonosSpeaker]]: """Pause all current coordinators and restore groups.""" for speaker in (s for s in speakers if s.is_coordinator): - if speaker.media.playback_status == SONOS_STATE_PLAYING: - speaker.soco.pause() + if ( + speaker.media.playback_status == SONOS_STATE_PLAYING + and "Pause" in speaker.soco.available_actions + ): + try: + speaker.soco.pause() + except SoCoUPnPException as exc: + _LOGGER.debug( + "Pause failed during restore of %s: %s", + speaker.zone_name, + speaker.soco.available_actions, + exc_info=exc, + ) groups = [] + if not with_group: + return groups - if with_group: - # Unjoin slaves first to prevent inheritance of queues - for speaker in [s for s in speakers if not s.is_coordinator]: - if speaker.snapshot_group != speaker.sonos_group: - speaker.unjoin() + # Unjoin non-coordinator speakers not contained in the desired snapshot group + # + # If a coordinator is unjoined from its group, another speaker from the group + # will inherit the coordinator's playqueue and its own playqueue will be lost + speakers_to_unjoin = set() + for speaker in speakers: + if speaker.sonos_group == speaker.snapshot_group: + continue - # Bring back the original group topology - for speaker in (s for s in speakers if s.snapshot_group): - assert speaker.snapshot_group is not None - if speaker.snapshot_group[0] == speaker: + speakers_to_unjoin.update( + { + s + for s in speaker.sonos_group[1:] + if s not in speaker.snapshot_group + } + ) + + for speaker in speakers_to_unjoin: + speaker.unjoin() + + # Bring back the original group topology + for speaker in (s for s in speakers if s.snapshot_group): + assert speaker.snapshot_group is not None + if speaker.snapshot_group[0] == speaker: + if ( + speaker.snapshot_group != speaker.sonos_group + and speaker.snapshot_group != [speaker] + ): speaker.join(speaker.snapshot_group) - groups.append(speaker.snapshot_group.copy()) + groups.append(speaker.snapshot_group.copy()) return groups @@ -836,6 +868,11 @@ class SonosSpeaker: # Find all affected players speakers_set = {s for s in speakers if s.soco_snapshot} + if missing_snapshots := set(speakers) - speakers_set: + raise HomeAssistantError( + f"Restore failed, speakers are missing snapshots: {[s.zone_name for s in missing_snapshots]}" + ) + if with_group: for speaker in [s for s in speakers_set if s.snapshot_group]: assert speaker.snapshot_group is not None From 032cae772a5400108d0f50e52db5b7e77b53aef3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 22 Jul 2021 12:04:27 -0600 Subject: [PATCH 107/112] Bump aionotion to 3.0.2 (#53354) --- homeassistant/components/notion/__init__.py | 2 +- homeassistant/components/notion/config_flow.py | 2 +- homeassistant/components/notion/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 6fff031ae25..8acf9c24d4a 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: client = await async_get_client( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) except InvalidCredentialsError: LOGGER.error("Invalid username and/or password") diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 4b654e4366e..ad6d8eb9519 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -44,7 +44,7 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await async_get_client( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session + user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=session ) except NotionError: return await self._show_form({"base": "invalid_auth"}) diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index 191f66ee59d..378d6442e31 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -3,7 +3,7 @@ "name": "Notion", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/notion", - "requirements": ["aionotion==1.1.0"], + "requirements": ["aionotion==3.0.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index d6916cc0122..9c9e88f22ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -218,7 +218,7 @@ aiomusiccast==0.8.0 aionotify==0.2.0 # homeassistant.components.notion -aionotion==1.1.0 +aionotion==3.0.2 # homeassistant.components.automate aiopulse2==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e8fa192141..c003da1711c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ aiomodernforms==0.1.8 aiomusiccast==0.8.0 # homeassistant.components.notion -aionotion==1.1.0 +aionotion==3.0.2 # homeassistant.components.automate aiopulse2==0.6.0 From 3461f61f9ff5a3dc08f00215d64920a99e543365 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Thu, 22 Jul 2021 14:11:36 -0400 Subject: [PATCH 108/112] Create APIs for Insteon panel (#49785) --- homeassistant/components/insteon/__init__.py | 3 + .../components/insteon/api/__init__.py | 44 ++ homeassistant/components/insteon/api/aldb.py | 309 +++++++++++++ .../components/insteon/api/device.py | 79 ++++ .../components/insteon/api/properties.py | 420 +++++++++++++++++ homeassistant/components/insteon/const.py | 14 + .../components/insteon/manifest.json | 2 +- homeassistant/components/insteon/schemas.py | 8 + tests/components/insteon/mock_connection.py | 11 + tests/components/insteon/mock_devices.py | 75 +++- tests/components/insteon/test_api_aldb.py | 288 ++++++++++++ tests/components/insteon/test_api_device.py | 139 ++++++ .../components/insteon/test_api_properties.py | 425 ++++++++++++++++++ tests/fixtures/insteon/aldb_data.json | 67 +++ tests/fixtures/insteon/kpl_properties.json | 66 +++ 15 files changed, 1943 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/insteon/api/__init__.py create mode 100644 homeassistant/components/insteon/api/aldb.py create mode 100644 homeassistant/components/insteon/api/device.py create mode 100644 homeassistant/components/insteon/api/properties.py create mode 100644 tests/components/insteon/mock_connection.py create mode 100644 tests/components/insteon/test_api_aldb.py create mode 100644 tests/components/insteon/test_api_device.py create mode 100644 tests/components/insteon/test_api_properties.py create mode 100644 tests/fixtures/insteon/aldb_data.json create mode 100644 tests/fixtures/insteon/kpl_properties.json diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 2e2d801e1f2..223448953b9 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import ConfigEntryNotReady +from . import api from .const import ( CONF_CAT, CONF_DIM_STEPS, @@ -164,6 +165,8 @@ async def async_setup_entry(hass, entry): sw_version=f"{devices.modem.firmware:02x} Engine Version: {devices.modem.engine_version}", ) + api.async_load_api(hass) + asyncio.create_task(async_get_device_config(hass, entry)) return True diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py new file mode 100644 index 00000000000..3b786a38343 --- /dev/null +++ b/homeassistant/components/insteon/api/__init__.py @@ -0,0 +1,44 @@ +"""Insteon API interface for the frontend.""" + +from homeassistant.components import websocket_api +from homeassistant.core import callback + +from .aldb import ( + websocket_add_default_links, + websocket_change_aldb_record, + websocket_create_aldb_record, + websocket_get_aldb, + websocket_load_aldb, + websocket_notify_on_aldb_status, + websocket_reset_aldb, + websocket_write_aldb, +) +from .device import websocket_get_device +from .properties import ( + websocket_change_properties_record, + websocket_get_properties, + websocket_load_properties, + websocket_reset_properties, + websocket_write_properties, +) + + +@callback +def async_load_api(hass): + """Set up the web socket API.""" + websocket_api.async_register_command(hass, websocket_get_device) + + websocket_api.async_register_command(hass, websocket_get_aldb) + websocket_api.async_register_command(hass, websocket_change_aldb_record) + websocket_api.async_register_command(hass, websocket_create_aldb_record) + websocket_api.async_register_command(hass, websocket_write_aldb) + websocket_api.async_register_command(hass, websocket_load_aldb) + websocket_api.async_register_command(hass, websocket_reset_aldb) + websocket_api.async_register_command(hass, websocket_add_default_links) + websocket_api.async_register_command(hass, websocket_notify_on_aldb_status) + + websocket_api.async_register_command(hass, websocket_get_properties) + websocket_api.async_register_command(hass, websocket_change_properties_record) + websocket_api.async_register_command(hass, websocket_write_properties) + websocket_api.async_register_command(hass, websocket_load_properties) + websocket_api.async_register_command(hass, websocket_reset_properties) diff --git a/homeassistant/components/insteon/api/aldb.py b/homeassistant/components/insteon/api/aldb.py new file mode 100644 index 00000000000..881cb0bb8c7 --- /dev/null +++ b/homeassistant/components/insteon/api/aldb.py @@ -0,0 +1,309 @@ +"""Web socket API for Insteon devices.""" + +from pyinsteon import devices +from pyinsteon.constants import ALDBStatus +from pyinsteon.topics import ( + ALDB_STATUS_CHANGED, + DEVICE_LINK_CONTROLLER_CREATED, + DEVICE_LINK_RESPONDER_CREATED, +) +from pyinsteon.utils import subscribe_topic, unsubscribe_topic +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from ..const import DEVICE_ADDRESS, ID, INSTEON_DEVICE_NOT_FOUND, TYPE +from .device import async_device_name, notify_device_not_found + +ALDB_RECORD = "record" +ALDB_RECORD_SCHEMA = vol.Schema( + { + vol.Required("mem_addr"): int, + vol.Required("in_use"): bool, + vol.Required("group"): vol.Range(0, 255), + vol.Required("is_controller"): bool, + vol.Optional("highwater"): bool, + vol.Required("target"): str, + vol.Optional("target_name"): str, + vol.Required("data1"): vol.Range(0, 255), + vol.Required("data2"): vol.Range(0, 255), + vol.Required("data3"): vol.Range(0, 255), + vol.Optional("dirty"): bool, + } +) + + +async def async_aldb_record_to_dict(dev_registry, record, dirty=False): + """Convert an ALDB record to a dict.""" + return ALDB_RECORD_SCHEMA( + { + "mem_addr": record.mem_addr, + "in_use": record.is_in_use, + "is_controller": record.is_controller, + "highwater": record.is_high_water_mark, + "group": record.group, + "target": str(record.target), + "target_name": await async_device_name(dev_registry, record.target), + "data1": record.data1, + "data2": record.data2, + "data3": record.data3, + "dirty": dirty, + } + ) + + +async def async_reload_and_save_aldb(hass, device): + """Add default links to an Insteon device.""" + if device == devices.modem: + await device.aldb.async_load() + else: + await device.aldb.async_load(refresh=True) + await devices.async_save(workdir=hass.config.config_dir) + + +@websocket_api.websocket_command( + {vol.Required(TYPE): "insteon/aldb/get", vol.Required(DEVICE_ADDRESS): str} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get the All-Link Database for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + # Convert the ALDB to a dict merge in pending changes + aldb = {mem_addr: device.aldb[mem_addr] for mem_addr in device.aldb} + aldb.update(device.aldb.pending_changes) + changed_records = list(device.aldb.pending_changes.keys()) + + dev_registry = await hass.helpers.device_registry.async_get_registry() + + records = [ + await async_aldb_record_to_dict( + dev_registry, aldb[mem_addr], mem_addr in changed_records + ) + for mem_addr in aldb + ] + + connection.send_result(msg[ID], records) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/change", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(ALDB_RECORD): ALDB_RECORD_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_change_aldb_record( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Change an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + record = msg[ALDB_RECORD] + device.aldb.modify( + mem_addr=record["mem_addr"], + in_use=record["in_use"], + group=record["group"], + controller=record["is_controller"], + target=record["target"], + data1=record["data1"], + data2=record["data2"], + data3=record["data3"], + ) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/create", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(ALDB_RECORD): ALDB_RECORD_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create_aldb_record( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + record = msg[ALDB_RECORD] + device.aldb.add( + group=record["group"], + controller=record["is_controller"], + target=record["target"], + data1=record["data1"], + data2=record["data2"], + data3=record["data3"], + ) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/write", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_write_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + await device.aldb.async_write() + hass.async_create_task(async_reload_and_save_aldb(hass, device)) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/load", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_load_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + hass.async_create_task(async_reload_and_save_aldb(hass, device)) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/reset", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_reset_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + device.aldb.clear_pending() + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/add_default_links", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_add_default_links( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + device.aldb.clear_pending() + await device.async_add_default_links() + hass.async_create_task(async_reload_and_save_aldb(hass, device)) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/notify", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_notify_on_aldb_status( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Tell Insteon a new ALDB record was added.""" + + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + @callback + def record_added(controller, responder, group): + """Forward ALDB events to websocket.""" + forward_data = {"type": "record_loaded"} + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + @callback + def aldb_loaded(): + """Forward ALDB loaded event to websocket.""" + forward_data = { + "type": "status_changed", + "is_loading": device.aldb.status == ALDBStatus.LOADING, + } + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + unsubscribe_topic(record_added, f"{DEVICE_LINK_CONTROLLER_CREATED}.{device.id}") + unsubscribe_topic(record_added, f"{DEVICE_LINK_RESPONDER_CREATED}.{device.id}") + unsubscribe_topic(aldb_loaded, f"{device.id}.{ALDB_STATUS_CHANGED}") + + forward_data = {"type": "unsubscribed"} + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + connection.subscriptions[msg["id"]] = async_cleanup + subscribe_topic(record_added, f"{DEVICE_LINK_CONTROLLER_CREATED}.{device.id}") + subscribe_topic(record_added, f"{DEVICE_LINK_RESPONDER_CREATED}.{device.id}") + subscribe_topic(aldb_loaded, f"{device.id}.{ALDB_STATUS_CHANGED}") + + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py new file mode 100644 index 00000000000..9d77e8b765c --- /dev/null +++ b/homeassistant/components/insteon/api/device.py @@ -0,0 +1,79 @@ +"""API interface to get an Insteon device.""" + +from pyinsteon import devices +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant + +from ..const import ( + DEVICE_ID, + DOMAIN, + HA_DEVICE_NOT_FOUND, + ID, + INSTEON_DEVICE_NOT_FOUND, + TYPE, +) + + +def compute_device_name(ha_device): + """Return the HA device name.""" + return ha_device.name_by_user if ha_device.name_by_user else ha_device.name + + +def get_insteon_device_from_ha_device(ha_device): + """Return the Insteon device from an HA device.""" + for identifier in ha_device.identifiers: + if len(identifier) > 1 and identifier[0] == DOMAIN and devices[identifier[1]]: + return devices[identifier[1]] + return None + + +async def async_device_name(dev_registry, address): + """Get the Insteon device name from a device registry id.""" + ha_device = dev_registry.async_get_device( + identifiers={(DOMAIN, str(address))}, connections=set() + ) + if not ha_device: + device = devices[address] + if device: + return f"{device.description} ({device.model})" + return "" + return compute_device_name(ha_device) + + +def notify_device_not_found(connection, msg, text): + """Notify the caller that the device was not found.""" + connection.send_message( + websocket_api.error_message(msg[ID], websocket_api.const.ERR_NOT_FOUND, text) + ) + + +@websocket_api.websocket_command( + {vol.Required(TYPE): "insteon/device/get", vol.Required(DEVICE_ID): str} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_device( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get an Insteon device.""" + dev_registry = await hass.helpers.device_registry.async_get_registry() + ha_device = dev_registry.async_get(msg[DEVICE_ID]) + if not ha_device: + notify_device_not_found(connection, msg, HA_DEVICE_NOT_FOUND) + return + device = get_insteon_device_from_ha_device(ha_device) + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + ha_name = compute_device_name(ha_device) + device_info = { + "name": ha_name, + "address": str(device.address), + "is_battery": device.is_battery, + "aldb_status": str(device.aldb.status), + } + connection.send_result(msg[ID], device_info) diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py new file mode 100644 index 00000000000..0b3b643b617 --- /dev/null +++ b/homeassistant/components/insteon/api/properties.py @@ -0,0 +1,420 @@ +"""Property update methods and schemas.""" +from itertools import chain + +from pyinsteon import devices +from pyinsteon.constants import RAMP_RATES, ResponseStatus +from pyinsteon.device_types.device_base import Device +from pyinsteon.extended_property import ( + NON_TOGGLE_MASK, + NON_TOGGLE_ON_OFF_MASK, + OFF_MASK, + ON_MASK, + RAMP_RATE, +) +from pyinsteon.utils import ramp_rate_to_seconds, seconds_to_ramp_rate +import voluptuous as vol +import voluptuous_serialize + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv + +from ..const import ( + DEVICE_ADDRESS, + ID, + INSTEON_DEVICE_NOT_FOUND, + PROPERTY_NAME, + PROPERTY_VALUE, + TYPE, +) +from .device import notify_device_not_found + +TOGGLE_ON_OFF_MODE = "toggle_on_off_mode" +NON_TOGGLE_ON_MODE = "non_toggle_on_mode" +NON_TOGGLE_OFF_MODE = "non_toggle_off_mode" +RADIO_BUTTON_GROUP_PROP = "radio_button_group_" +TOGGLE_PROP = "toggle_" +RAMP_RATE_SECONDS = list(dict.fromkeys(RAMP_RATES.values())) +RAMP_RATE_SECONDS.sort() +TOGGLE_MODES = {TOGGLE_ON_OFF_MODE: 0, NON_TOGGLE_ON_MODE: 1, NON_TOGGLE_OFF_MODE: 2} +TOGGLE_MODES_SCHEMA = { + 0: TOGGLE_ON_OFF_MODE, + 1: NON_TOGGLE_ON_MODE, + 2: NON_TOGGLE_OFF_MODE, +} + + +def _bool_schema(name): + return voluptuous_serialize.convert(vol.Schema({vol.Required(name): bool}))[0] + + +def _byte_schema(name): + return voluptuous_serialize.convert(vol.Schema({vol.Required(name): cv.byte}))[0] + + +def _ramp_rate_schema(name): + return voluptuous_serialize.convert( + vol.Schema({vol.Required(name): vol.In(RAMP_RATE_SECONDS)}), + custom_serializer=cv.custom_serializer, + )[0] + + +def get_properties(device: Device): + """Get the properties of an Insteon device and return the records and schema.""" + + properties = [] + schema = {} + + # Limit the properties we manage at this time. + for prop_name in device.operating_flags: + if not device.operating_flags[prop_name].is_read_only: + prop_dict, schema_dict = _get_property(device.operating_flags[prop_name]) + properties.append(prop_dict) + schema[prop_name] = schema_dict + + mask_found = False + for prop_name in device.properties: + if device.properties[prop_name].is_read_only: + continue + + if prop_name == RAMP_RATE: + rr_prop, rr_schema = _get_ramp_rate_property(device.properties[prop_name]) + properties.append(rr_prop) + schema[RAMP_RATE] = rr_schema + + elif not mask_found and "mask" in prop_name: + mask_found = True + toggle_props, toggle_schema = _get_toggle_properties(device) + properties.extend(toggle_props) + schema.update(toggle_schema) + + rb_props, rb_schema = _get_radio_button_properties(device) + properties.extend(rb_props) + schema.update(rb_schema) + else: + prop_dict, schema_dict = _get_property(device.properties[prop_name]) + properties.append(prop_dict) + schema[prop_name] = schema_dict + + return properties, schema + + +def set_property(device, prop_name: str, value): + """Update a property value.""" + if isinstance(value, bool) and prop_name in device.operating_flags: + device.operating_flags[prop_name].new_value = value + + elif prop_name == RAMP_RATE: + device.properties[prop_name].new_value = seconds_to_ramp_rate(value) + + elif prop_name.startswith(RADIO_BUTTON_GROUP_PROP): + buttons = [int(button) for button in value] + rb_groups = _calc_radio_button_groups(device) + curr_group = int(prop_name[len(RADIO_BUTTON_GROUP_PROP) :]) + if len(rb_groups) > curr_group: + removed = [btn for btn in rb_groups[curr_group] if btn not in buttons] + if removed: + device.clear_radio_buttons(removed) + if buttons: + device.set_radio_buttons(buttons) + + elif prop_name.startswith(TOGGLE_PROP): + button_name = prop_name[len(TOGGLE_PROP) :] + for button in device.groups: + if device.groups[button].name == button_name: + device.set_toggle_mode(button, int(value)) + + else: + device.properties[prop_name].new_value = value + + +def _get_property(prop): + """Return a property data row.""" + value, modified = _get_usable_value(prop) + prop_dict = {"name": prop.name, "value": value, "modified": modified} + if isinstance(prop.value, bool): + schema = _bool_schema(prop.name) + else: + schema = _byte_schema(prop.name) + return prop_dict, {"name": prop.name, **schema} + + +def _get_toggle_properties(device): + """Generate the mask properties for a KPL device.""" + props = [] + schema = {} + toggle_prop = device.properties[NON_TOGGLE_MASK] + toggle_on_prop = device.properties[NON_TOGGLE_ON_OFF_MASK] + for button in device.groups: + name = f"{TOGGLE_PROP}{device.groups[button].name}" + value, modified = _toggle_button_value(toggle_prop, toggle_on_prop, button) + props.append({"name": name, "value": value, "modified": modified}) + toggle_schema = vol.Schema({vol.Required(name): vol.In(TOGGLE_MODES_SCHEMA)}) + toggle_schema_dict = voluptuous_serialize.convert( + toggle_schema, custom_serializer=cv.custom_serializer + ) + schema[name] = toggle_schema_dict[0] + return props, schema + + +def _toggle_button_value(non_toggle_prop, toggle_on_prop, button): + """Determine the toggle value of a button.""" + toggle_mask, toggle_modified = _get_usable_value(non_toggle_prop) + toggle_on_mask, toggle_on_modified = _get_usable_value(toggle_on_prop) + + bit = button - 1 + if not toggle_mask & 1 << bit: + value = 0 + else: + if toggle_on_mask & 1 << bit: + value = 1 + else: + value = 2 + + modified = False + if toggle_modified: + curr_bit = non_toggle_prop.value & 1 << bit + new_bit = non_toggle_prop.new_value & 1 << bit + modified = not curr_bit == new_bit + + if not modified and value != 0 and toggle_on_modified: + curr_bit = toggle_on_prop.value & 1 << bit + new_bit = toggle_on_prop.new_value & 1 << bit + modified = not curr_bit == new_bit + + return value, modified + + +def _get_radio_button_properties(device): + """Return the values and schema to set KPL buttons as radio buttons.""" + rb_groups = _calc_radio_button_groups(device) + props = [] + schema = {} + index = 0 + remaining_buttons = [] + + buttons_in_groups = list(chain.from_iterable(rb_groups)) + + # Identify buttons not belonging to any group + for button in device.groups: + if button not in buttons_in_groups: + remaining_buttons.append(button) + + for rb_group in rb_groups: + name = f"{RADIO_BUTTON_GROUP_PROP}{index}" + button_1 = rb_group[0] + button_str = f"_{button_1}" if button_1 != 1 else "" + on_mask = device.properties[f"{ON_MASK}{button_str}"] + off_mask = device.properties[f"{OFF_MASK}{button_str}"] + modified = on_mask.is_dirty or off_mask.is_dirty + + props.append( + { + "name": name, + "modified": modified, + "value": rb_group, + } + ) + + options = { + button: device.groups[button].name + for button in chain.from_iterable([rb_group, remaining_buttons]) + } + rb_schema = vol.Schema({vol.Optional(name): cv.multi_select(options)}) + + rb_schema_dict = voluptuous_serialize.convert( + rb_schema, custom_serializer=cv.custom_serializer + ) + schema[name] = rb_schema_dict[0] + + index += 1 + + if len(remaining_buttons) > 1: + name = f"{RADIO_BUTTON_GROUP_PROP}{index}" + + props.append( + { + "name": name, + "modified": False, + "value": [], + } + ) + + options = {button: device.groups[button].name for button in remaining_buttons} + rb_schema = vol.Schema({vol.Optional(name): cv.multi_select(options)}) + + rb_schema_dict = voluptuous_serialize.convert( + rb_schema, custom_serializer=cv.custom_serializer + ) + schema[name] = rb_schema_dict[0] + + return props, schema + + +def _calc_radio_button_groups(device): + """Return existing radio button groups.""" + rb_groups = [] + for button in device.groups: + if button not in list(chain.from_iterable(rb_groups)): + button_str = "" if button == 1 else f"_{button}" + on_mask, _ = _get_usable_value(device.properties[f"{ON_MASK}{button_str}"]) + if on_mask != 0: + rb_group = [button] + for bit in list(range(0, button - 1)) + list(range(button, 8)): + if on_mask & 1 << bit: + rb_group.append(bit + 1) + if len(rb_group) > 1: + rb_groups.append(rb_group) + return rb_groups + + +def _get_ramp_rate_property(prop): + """Return the value and schema of a ramp rate property.""" + rr_prop, _ = _get_property(prop) + rr_prop["value"] = ramp_rate_to_seconds(rr_prop["value"]) + return rr_prop, _ramp_rate_schema(prop.name) + + +def _get_usable_value(prop): + """Return the current or the modified value of a property.""" + value = prop.value if prop.new_value is None else prop.new_value + return value, prop.is_dirty + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/get", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + properties, schema = get_properties(device) + + connection.send_result(msg[ID], {"properties": properties, "schema": schema}) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/change", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(PROPERTY_NAME): str, + vol.Required(PROPERTY_VALUE): vol.Any(list, int, float, bool, str), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_change_properties_record( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + set_property(device, msg[PROPERTY_NAME], msg[PROPERTY_VALUE]) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/write", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_write_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + result1 = await device.async_write_op_flags() + result2 = await device.async_write_ext_properties() + await devices.async_save(workdir=hass.config.config_dir) + if result1 != ResponseStatus.SUCCESS or result2 != ResponseStatus.SUCCESS: + connection.send_message( + websocket_api.error_message( + msg[ID], "write_failed", "properties not written to device" + ) + ) + return + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/load", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_load_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + result1 = await device.async_read_op_flags() + result2 = await device.async_read_ext_properties() + await devices.async_save(workdir=hass.config.config_dir) + if result1 != ResponseStatus.SUCCESS or result2 != ResponseStatus.SUCCESS: + connection.send_message( + websocket_api.error_message( + msg[ID], "load_failed", "properties not loaded from device" + ) + ) + return + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/reset", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_reset_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + for prop in device.operating_flags: + device.operating_flags[prop].new_value = None + for prop in device.properties: + device.properties[prop].new_value = None + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py index a40a0b0d4b0..dca53d20369 100644 --- a/homeassistant/components/insteon/const.py +++ b/homeassistant/components/insteon/const.py @@ -1,4 +1,6 @@ """Constants used by insteon component.""" +import re + from pyinsteon.groups import ( CO_SENSOR, COVER, @@ -158,3 +160,15 @@ STATE_NAME_LABEL_MAP = { COVER: "Cover", RELAY: "Relay", } + +TYPE = "type" +ID = "id" +DEVICE_ID = "device_id" +DEVICE_ADDRESS = "device_address" +ALDB_RECORD = "record" +PROPERTY_NAME = "name" +PROPERTY_VALUE = "value" +HA_DEVICE_NOT_FOUND = "ha_device_not_found" +INSTEON_DEVICE_NOT_FOUND = "insteon_device_not_found" + +INSTEON_ADDR_REGEX = re.compile(r"([A-Fa-f0-9]{2}\.?[A-Fa-f0-9]{2}\.?[A-Fa-f0-9]{2})$") diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 4643a8c662a..f5f9d57d8a8 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -10,4 +10,4 @@ ], "config_flow": true, "iot_class": "local_push" -} \ No newline at end of file +} diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 5fb46735f29..626dc7dde4b 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -40,6 +40,7 @@ from .const import ( CONF_X10_ALL_UNITS_OFF, DOMAIN, HOUSECODES, + INSTEON_ADDR_REGEX, PORT_HUB_V1, PORT_HUB_V2, SRV_ALL_LINK_GROUP, @@ -64,6 +65,13 @@ def set_default_port(schema: dict) -> dict: return schema +def insteon_address(value: str) -> str: + """Validate an Insteon address.""" + if not INSTEON_ADDR_REGEX.match(value): + raise vol.Invalid("Invalid Insteon Address") + return str(value).replace(".", "").lower() + + CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( vol.Schema( { diff --git a/tests/components/insteon/mock_connection.py b/tests/components/insteon/mock_connection.py new file mode 100644 index 00000000000..00d2c1ec83a --- /dev/null +++ b/tests/components/insteon/mock_connection.py @@ -0,0 +1,11 @@ +"""Mock connections for Insteon.""" + + +async def mock_successful_connection(*args, **kwargs): + """Return a successful connection.""" + return True + + +async def mock_failed_connection(*args, **kwargs): + """Return a failed connection.""" + raise ConnectionError("Connection failed") diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py index 7ffb0672161..e28e25bf41b 100644 --- a/tests/components/insteon/mock_devices.py +++ b/tests/components/insteon/mock_devices.py @@ -2,11 +2,14 @@ from unittest.mock import AsyncMock, MagicMock from pyinsteon.address import Address +from pyinsteon.constants import ALDBStatus, ResponseStatus from pyinsteon.device_types import ( - GeneralController_MiniRemote_4, + DimmableLightingControl_KeypadLinc_8, + GeneralController, Hub, SwitchedLightingControl_SwitchLinc, ) +from pyinsteon.managers.saved_devices_manager import dict_to_aldb_record class MockSwitchLinc(SwitchedLightingControl_SwitchLinc): @@ -32,7 +35,7 @@ class MockDevices: def __getitem__(self, address): """Return a a device from the device address.""" - return self._devices.get(address) + return self._devices.get(Address(address)) def __iter__(self): """Return an iterator of device addresses.""" @@ -53,13 +56,73 @@ class MockDevices: addr1 = Address("11.11.11") addr2 = Address("22.22.22") addr3 = Address("33.33.33") - self._devices[addr0] = Hub(addr0) - self._devices[addr1] = MockSwitchLinc(addr1, 0x02, 0x00) - self._devices[addr2] = GeneralController_MiniRemote_4(addr2, 0x00, 0x00) - self._devices[addr3] = SwitchedLightingControl_SwitchLinc(addr3, 0x02, 0x00) + self._devices[addr0] = Hub(addr0, 0x03, 0x00, 0x00, "Hub AA.AA.AA", "0") + self._devices[addr1] = MockSwitchLinc( + addr1, 0x02, 0x00, 0x00, "Device 11.11.11", "1" + ) + self._devices[addr2] = GeneralController( + addr2, 0x00, 0x00, 0x00, "Device 22.22.22", "2" + ) + self._devices[addr3] = DimmableLightingControl_KeypadLinc_8( + addr3, 0x02, 0x00, 0x00, "Device 33.33.33", "3" + ) + for device in [self._devices[addr] for addr in [addr1, addr2, addr3]]: device.async_read_config = AsyncMock() + device.aldb.async_write = AsyncMock() + device.aldb.async_load = AsyncMock() + device.async_add_default_links = AsyncMock() + device.async_read_op_flags = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + device.async_read_ext_properties = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + device.async_write_op_flags = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + device.async_write_ext_properties = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + for device in [self._devices[addr] for addr in [addr2, addr3]]: device.async_status = AsyncMock() self._devices[addr1].async_status = AsyncMock(side_effect=AttributeError) + self._devices[addr0].aldb.async_load = AsyncMock() + + self._devices[addr2].async_read_op_flags = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self._devices[addr2].async_read_ext_properties = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self._devices[addr2].async_write_op_flags = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self._devices[addr2].async_write_ext_properties = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self.modem = self._devices[addr0] + + def fill_aldb(self, address, records): + """Fill the All-Link Database for a device.""" + device = self._devices[Address(address)] + aldb_records = dict_to_aldb_record(records) + + device.aldb.load_saved_records(ALDBStatus.LOADED, aldb_records) + + def fill_properties(self, address, props_dict): + """Fill the operating flags and extended properties of a device.""" + device = self._devices[Address(address)] + operating_flags = props_dict.get("operating_flags", {}) + properties = props_dict.get("properties", {}) + + for flag in operating_flags: + value = operating_flags[flag] + if device.operating_flags.get(flag): + device.operating_flags[flag].load(value) + for flag in properties: + value = properties[flag] + if device.properties.get(flag): + device.properties[flag].load(value) diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py new file mode 100644 index 00000000000..d360b34d7b9 --- /dev/null +++ b/tests/components/insteon/test_api_aldb.py @@ -0,0 +1,288 @@ +"""Test the Insteon All-Link Database APIs.""" + +import json +from unittest.mock import patch + +from pyinsteon import pub +from pyinsteon.address import Address +from pyinsteon.topics import ALDB_STATUS_CHANGED, DEVICE_LINK_CONTROLLER_CREATED +import pytest + +from homeassistant.components import insteon +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.api.aldb import ( + ALDB_RECORD, + DEVICE_ADDRESS, + ID, + TYPE, +) +from homeassistant.components.insteon.api.device import INSTEON_DEVICE_NOT_FOUND + +from .mock_devices import MockDevices + +from tests.common import load_fixture + + +@pytest.fixture(name="aldb_data", scope="session") +def aldb_data_fixture(): + """Load the controller state fixture data.""" + return json.loads(load_fixture("insteon/aldb_data.json")) + + +async def _setup(hass, hass_ws_client, aldb_data): + """Set up tests.""" + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + async_load_api(hass) + devices.fill_aldb("33.33.33", aldb_data) + return ws_client, devices + + +def _compare_records(aldb_rec, dict_rec): + """Compare a record in the ALDB to the dictionary record.""" + assert aldb_rec.is_in_use == dict_rec["in_use"] + assert aldb_rec.is_controller == (dict_rec["is_controller"]) + assert not aldb_rec.is_high_water_mark + assert aldb_rec.group == dict_rec["group"] + assert aldb_rec.target == Address(dict_rec["target"]) + assert aldb_rec.data1 == dict_rec["data1"] + assert aldb_rec.data2 == dict_rec["data2"] + assert aldb_rec.data3 == dict_rec["data3"] + + +def _aldb_dict(mem_addr): + """Generate an ALDB record as a dictionary.""" + return { + "mem_addr": mem_addr, + "in_use": True, + "is_controller": True, + "highwater": False, + "group": 100, + "target": "111111", + "data1": 101, + "data2": 102, + "data3": 103, + "dirty": True, + } + + +async def test_get_aldb(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/aldb/get", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert len(result) == 5 + + +async def test_change_aldb_record(hass, hass_ws_client, aldb_data): + """Test changing an Insteon device's All-Link Database record.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + change_rec = _aldb_dict(4079) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/change", + DEVICE_ADDRESS: "33.33.33", + ALDB_RECORD: change_rec, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(devices["33.33.33"].aldb.pending_changes) == 1 + rec = devices["33.33.33"].aldb.pending_changes[4079] + _compare_records(rec, change_rec) + + +async def test_create_aldb_record(hass, hass_ws_client, aldb_data): + """Test creating a new Insteon All-Link Database record.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + new_rec = _aldb_dict(4079) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/create", + DEVICE_ADDRESS: "33.33.33", + ALDB_RECORD: new_rec, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(devices["33.33.33"].aldb.pending_changes) == 1 + rec = devices["33.33.33"].aldb.pending_changes[-1] + _compare_records(rec, new_rec) + + +async def test_write_aldb(hass, hass_ws_client, aldb_data): + """Test writing an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/write", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].aldb.async_write.call_count == 1 + assert devices["33.33.33"].aldb.async_load.call_count == 1 + assert devices.async_save.call_count == 1 + + +async def test_load_aldb(hass, hass_ws_client, aldb_data): + """Test loading an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/load", + DEVICE_ADDRESS: "AA.AA.AA", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["AA.AA.AA"].aldb.async_load.call_count == 1 + assert devices.async_save.call_count == 1 + + +async def test_reset_aldb(hass, hass_ws_client, aldb_data): + """Test resetting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + record = _aldb_dict(4079) + devices["33.33.33"].aldb.modify( + mem_addr=record["mem_addr"], + in_use=record["in_use"], + group=record["group"], + controller=record["is_controller"], + target=record["target"], + data1=record["data1"], + data2=record["data2"], + data3=record["data3"], + ) + + assert devices["33.33.33"].aldb.pending_changes + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/reset", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert not devices["33.33.33"].aldb.pending_changes + + +async def test_default_links(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/add_default_links", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].async_add_default_links.call_count == 1 + assert devices["33.33.33"].aldb.async_load.call_count == 1 + assert devices.async_save.call_count == 1 + + +async def test_notify_on_aldb_status(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/notify", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + pub.sendMessage(f"333333.{ALDB_STATUS_CHANGED}") + msg = await ws_client.receive_json() + assert msg["event"]["type"] == "status_changed" + assert not msg["event"]["is_loading"] + + +async def test_notify_on_aldb_record_added(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/notify", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + pub.sendMessage( + f"{DEVICE_LINK_CONTROLLER_CREATED}.333333", + controller=Address("11.11.11"), + responder=Address("33.33.33"), + group=100, + ) + msg = await ws_client.receive_json() + assert msg["event"]["type"] == "record_loaded" + + +async def test_bad_address(hass, hass_ws_client, aldb_data): + """Test for a bad Insteon address.""" + ws_client, _ = await _setup(hass, hass_ws_client, aldb_data) + record = _aldb_dict(0) + + ws_id = 0 + for call in ["get", "write", "load", "reset", "add_default_links", "notify"]: + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: f"insteon/aldb/{call}", + DEVICE_ADDRESS: "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + for call in ["change", "create"]: + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: f"insteon/aldb/{call}", + DEVICE_ADDRESS: "99.99.99", + ALDB_RECORD: record, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py new file mode 100644 index 00000000000..528d44cc691 --- /dev/null +++ b/tests/components/insteon/test_api_device.py @@ -0,0 +1,139 @@ +"""Test the device level APIs.""" +from unittest.mock import patch + +from homeassistant.components import insteon +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.api.device import ( + DEVICE_ID, + HA_DEVICE_NOT_FOUND, + ID, + INSTEON_DEVICE_NOT_FOUND, + TYPE, + async_device_name, +) +from homeassistant.components.insteon.const import DOMAIN +from homeassistant.helpers.device_registry import async_get_registry + +from .const import MOCK_USER_INPUT_PLM +from .mock_devices import MockDevices + +from tests.common import MockConfigEntry + + +async def _async_setup(hass, hass_ws_client): + """Set up for tests.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=MOCK_USER_INPUT_PLM, + options={}, + ) + config_entry.add_to_hass(hass) + async_load_api(hass) + + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + + dev_reg = await async_get_registry(hass) + # Create device registry entry for mock node + ha_device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "11.11.11")}, + name="Device 11.11.11", + ) + return ws_client, devices, ha_device, dev_reg + + +async def test_get_device_api(hass, hass_ws_client): + """Test getting an Insteon device.""" + + ws_client, devices, ha_device, _ = await _async_setup(hass, hass_ws_client) + with patch.object(insteon.api.device, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: ha_device.id} + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["name"] == "Device 11.11.11" + assert result["address"] == "11.11.11" + + +async def test_no_ha_device(hass, hass_ws_client): + """Test response when no HA device exists.""" + + ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + with patch.object(insteon.api.device, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: "not_a_device"} + ) + msg = await ws_client.receive_json() + assert not msg.get("result") + assert msg.get("error") + assert msg["error"]["message"] == HA_DEVICE_NOT_FOUND + + +async def test_no_insteon_device(hass, hass_ws_client): + """Test response when no Insteon device exists.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=MOCK_USER_INPUT_PLM, + options={}, + ) + config_entry.add_to_hass(hass) + async_load_api(hass) + + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + + dev_reg = await async_get_registry(hass) + # Create device registry entry for a Insteon device not in the Insteon devices list + ha_device_1 = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "AA.BB.CC")}, + name="HA Device Only", + ) + # Create device registry entry for a non-Insteon device + ha_device_2 = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("other_domain", "no address")}, + name="HA Device Only", + ) + with patch.object(insteon.api.device, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: ha_device_1.id} + ) + msg = await ws_client.receive_json() + assert not msg.get("result") + assert msg.get("error") + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + await ws_client.send_json( + {ID: 3, TYPE: "insteon/device/get", DEVICE_ID: ha_device_2.id} + ) + msg = await ws_client.receive_json() + assert not msg.get("result") + assert msg.get("error") + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + +async def test_get_ha_device_name(hass, hass_ws_client): + """Test getting the HA device name from an Insteon address.""" + + _, devices, _, device_reg = await _async_setup(hass, hass_ws_client) + + with patch.object(insteon.api.device, "devices", devices): + # Test a real HA and Insteon device + name = await async_device_name(device_reg, "11.11.11") + assert name == "Device 11.11.11" + + # Test no HA device but a real Insteon device + name = await async_device_name(device_reg, "22.22.22") + assert name == "Device 22.22.22 (2)" + + # Test no HA or Insteon device + name = await async_device_name(device_reg, "BB.BB.BB") + assert name == "" diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py new file mode 100644 index 00000000000..9b628f4443a --- /dev/null +++ b/tests/components/insteon/test_api_properties.py @@ -0,0 +1,425 @@ +"""Test the Insteon properties APIs.""" + +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import insteon +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.api.device import INSTEON_DEVICE_NOT_FOUND +from homeassistant.components.insteon.api.properties import ( + DEVICE_ADDRESS, + ID, + NON_TOGGLE_MASK, + NON_TOGGLE_OFF_MODE, + NON_TOGGLE_ON_MODE, + NON_TOGGLE_ON_OFF_MASK, + PROPERTY_NAME, + PROPERTY_VALUE, + RADIO_BUTTON_GROUP_PROP, + TOGGLE_MODES, + TOGGLE_ON_OFF_MODE, + TOGGLE_PROP, + TYPE, + _get_radio_button_properties, + _get_toggle_properties, +) + +from .mock_devices import MockDevices + +from tests.common import load_fixture + + +@pytest.fixture(name="properties_data", scope="session") +def aldb_data_fixture(): + """Load the controller state fixture data.""" + return json.loads(load_fixture("insteon/kpl_properties.json")) + + +async def _setup(hass, hass_ws_client, properties_data): + """Set up tests.""" + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + devices.fill_properties("33.33.33", properties_data) + async_load_api(hass) + return ws_client, devices + + +async def test_get_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/get", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["properties"]) == 54 + + +async def test_change_operating_flag(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: "led_off", + PROPERTY_VALUE: True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].operating_flags["led_off"].is_dirty + + +async def test_change_property(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: "on_mask", + PROPERTY_VALUE: 100, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].properties["on_mask"].new_value == 100 + assert devices["33.33.33"].properties["on_mask"].is_dirty + + +async def test_change_ramp_rate_property(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: "ramp_rate", + PROPERTY_VALUE: 4.5, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].properties["ramp_rate"].new_value == 0x1A + assert devices["33.33.33"].properties["ramp_rate"].is_dirty + + +async def test_change_radio_button_group(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + rb_props, schema = _get_radio_button_properties(devices["33.33.33"]) + + # Make sure the baseline is correct + assert rb_props[0]["name"] == f"{RADIO_BUTTON_GROUP_PROP}0" + assert rb_props[0]["value"] == [4, 5] + assert rb_props[1]["value"] == [7, 8] + assert rb_props[2]["value"] == [] + assert schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + assert devices["33.33.33"].properties["on_mask"].value == 0 + assert devices["33.33.33"].properties["off_mask"].value == 0 + assert not devices["33.33.33"].properties["on_mask"].is_dirty + assert not devices["33.33.33"].properties["off_mask"].is_dirty + + # Add button 1 to the group + rb_props[0]["value"].append(1) + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}0", + PROPERTY_VALUE: rb_props[0]["value"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + assert 1 in new_rb_props[0]["value"] + assert 4 in new_rb_props[0]["value"] + assert 5 in new_rb_props[0]["value"] + assert schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + + assert devices["33.33.33"].properties["on_mask"].new_value == 0x18 + assert devices["33.33.33"].properties["off_mask"].new_value == 0x18 + assert devices["33.33.33"].properties["on_mask"].is_dirty + assert devices["33.33.33"].properties["off_mask"].is_dirty + + # Remove button 5 + rb_props[0]["value"].remove(5) + await ws_client.send_json( + { + ID: 3, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}0", + PROPERTY_VALUE: rb_props[0]["value"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + assert 1 in new_rb_props[0]["value"] + assert 4 in new_rb_props[0]["value"] + assert 5 not in new_rb_props[0]["value"] + assert schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + + assert devices["33.33.33"].properties["on_mask"].new_value == 0x08 + assert devices["33.33.33"].properties["off_mask"].new_value == 0x08 + assert devices["33.33.33"].properties["on_mask"].is_dirty + assert devices["33.33.33"].properties["off_mask"].is_dirty + + # Remove button group 1 + rb_props[1]["value"] = [] + await ws_client.send_json( + { + ID: 5, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}1", + PROPERTY_VALUE: rb_props[1]["value"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + assert len(new_rb_props) == 2 + assert new_rb_props[0]["value"] == [1, 4] + assert new_rb_props[1]["value"] == [] + + +async def test_create_radio_button_group(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + + # Make sure the baseline is correct + assert len(rb_props) == 3 + print(rb_props) + + rb_props[0]["value"].append("1") + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}2", + PROPERTY_VALUE: ["1", "3"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, new_schema = _get_radio_button_properties(devices["33.33.33"]) + assert len(new_rb_props) == 4 + assert 1 in new_rb_props[0]["value"] + assert new_schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert not new_schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + + assert devices["33.33.33"].properties["on_mask"].new_value == 4 + assert devices["33.33.33"].properties["off_mask"].new_value == 4 + assert devices["33.33.33"].properties["on_mask"].is_dirty + assert devices["33.33.33"].properties["off_mask"].is_dirty + + +async def test_change_toggle_property(hass, hass_ws_client, properties_data): + """Update a button's toggle mode.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + device = devices["33.33.33"] + toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + + # Make sure the baseline is correct + assert toggle_props[0]["name"] == f"{TOGGLE_PROP}{device.groups[1].name}" + assert toggle_props[0]["value"] == TOGGLE_MODES[TOGGLE_ON_OFF_MODE] + assert toggle_props[1]["value"] == TOGGLE_MODES[NON_TOGGLE_ON_MODE] + assert device.properties[NON_TOGGLE_MASK].value == 2 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].value == 2 + assert not device.properties[NON_TOGGLE_MASK].is_dirty + assert not device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: toggle_props[0]["name"], + PROPERTY_VALUE: 1, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + assert new_toggle_props[0]["value"] == TOGGLE_MODES[NON_TOGGLE_ON_MODE] + assert device.properties[NON_TOGGLE_MASK].new_value == 3 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].new_value == 3 + assert device.properties[NON_TOGGLE_MASK].is_dirty + assert device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + await ws_client.send_json( + { + ID: 3, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: toggle_props[0]["name"], + PROPERTY_VALUE: 2, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + assert new_toggle_props[0]["value"] == TOGGLE_MODES[NON_TOGGLE_OFF_MODE] + assert device.properties[NON_TOGGLE_MASK].new_value == 3 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].new_value is None + assert device.properties[NON_TOGGLE_MASK].is_dirty + assert not device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + await ws_client.send_json( + { + ID: 4, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: toggle_props[1]["name"], + PROPERTY_VALUE: 0, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + assert new_toggle_props[1]["value"] == TOGGLE_MODES[TOGGLE_ON_OFF_MODE] + assert device.properties[NON_TOGGLE_MASK].new_value == 1 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].new_value == 0 + assert device.properties[NON_TOGGLE_MASK].is_dirty + assert device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + +async def test_write_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/write", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].async_write_op_flags.call_count == 1 + assert devices["33.33.33"].async_write_ext_properties.call_count == 1 + + +async def test_write_properties_failure(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/write", DEVICE_ADDRESS: "22.22.22"} + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "write_failed" + + +async def test_load_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/load", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].async_read_op_flags.call_count == 1 + assert devices["33.33.33"].async_read_ext_properties.call_count == 1 + + +async def test_load_properties_failure(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/load", DEVICE_ADDRESS: "22.22.22"} + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "load_failed" + + +async def test_reset_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + device = devices["33.33.33"] + device.operating_flags["led_off"].new_value = True + device.properties["on_mask"].new_value = 100 + assert device.operating_flags["led_off"].is_dirty + assert device.properties["on_mask"].is_dirty + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/reset", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert not device.operating_flags["led_off"].is_dirty + assert not device.properties["on_mask"].is_dirty + + +async def test_bad_address(hass, hass_ws_client, properties_data): + """Test for a bad Insteon address.""" + ws_client, _ = await _setup(hass, hass_ws_client, properties_data) + + ws_id = 0 + for call in ["get", "write", "load", "reset"]: + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: f"insteon/properties/{call}", + DEVICE_ADDRESS: "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "99.99.99", + PROPERTY_NAME: "led_off", + PROPERTY_VALUE: True, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND diff --git a/tests/fixtures/insteon/aldb_data.json b/tests/fixtures/insteon/aldb_data.json new file mode 100644 index 00000000000..2cab1dd5050 --- /dev/null +++ b/tests/fixtures/insteon/aldb_data.json @@ -0,0 +1,67 @@ +{ + "4095": { + "memory": 4095, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "aaaaaa", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4087": { + "memory": 4087, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "aaaaaa", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4079": { + "memory": 4079, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "111111", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4071": { + "memory": 4071, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 2, + "target": "222222", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4063": { + "memory": 4063, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 3, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + } +} \ No newline at end of file diff --git a/tests/fixtures/insteon/kpl_properties.json b/tests/fixtures/insteon/kpl_properties.json new file mode 100644 index 00000000000..1115428a073 --- /dev/null +++ b/tests/fixtures/insteon/kpl_properties.json @@ -0,0 +1,66 @@ +{ + "operating_flags": { + "program_lock_on": false, + "blink_on_tx_on": false, + "resume_dim_on": false, + "led_on": false, + "key_beep_on": false, + "rf_disable_on": false, + "powerline_disable_on": false, + "blink_on_error_on": false + }, + "properties": { + "led_dimming": 10, + "non_toggle_mask": 2, + "non_toggle_on_off_mask": 2, + "trigger_group_mask": 0, + "on_mask": 0, + "off_mask": 0, + "x10_house": 32, + "x10_unit": 32, + "ramp_rate": 28, + "on_level": 255, + "on_mask_2": 0, + "off_mask_2": 0, + "x10_house_2": 32, + "x10_unit_2": 32, + "ramp_rate_2": 0, + "on_level_2": 0, + "on_mask_3": 0, + "off_mask_3": 0, + "x10_house_3": 32, + "x10_unit_3": 32, + "ramp_rate_3": 0, + "on_level_3": 0, + "on_mask_4": 16, + "off_mask_4": 16, + "x10_house_4": 32, + "x10_unit_4": 32, + "ramp_rate_4": 0, + "on_level_4": 0, + "on_mask_5": 0, + "off_mask_5": 0, + "x10_house_5": 32, + "x10_unit_5": 32, + "ramp_rate_5": 0, + "on_level_5": 0, + "on_mask_6": 0, + "off_mask_6": 0, + "x10_house_6": 32, + "x10_unit_6": 32, + "ramp_rate_6": 0, + "on_level_6": 0, + "on_mask_7": 128, + "off_mask_7": 128, + "x10_house_7": 32, + "x10_unit_7": 32, + "ramp_rate_7": 0, + "on_level_7": 0, + "on_mask_8": 64, + "off_mask_8": 64, + "x10_house_8": 32, + "x10_unit_8": 2, + "ramp_rate_8": 98, + "on_level_8": 74 + } +} \ No newline at end of file From 75f7d3d696659b302627fac277fcf7844414802a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 22 Jul 2021 20:12:33 +0200 Subject: [PATCH 109/112] Replace util.get_local_ip in favor of components.network.async_get_source_ip() - part 1 (#52980) --- .../components/dlna_dmr/manifest.json | 1 + .../components/dlna_dmr/media_player.py | 5 +++-- homeassistant/components/fritz/switch.py | 19 +++++++++++++------ .../components/local_ip/manifest.json | 1 + homeassistant/components/local_ip/sensor.py | 9 ++++++--- homeassistant/components/network/__init__.py | 2 +- homeassistant/components/network/const.py | 2 +- homeassistant/components/upnp/__init__.py | 5 +++-- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/zeroconf/__init__.py | 5 +++-- tests/components/local_ip/test_init.py | 5 +++-- 11 files changed, 36 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index d11b32a6dd5..e9ac437fe46 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,6 +3,7 @@ "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "requirements": ["async-upnp-client==0.19.1"], + "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index b2999a5ae56..36f62155b2d 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -24,6 +24,8 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import ( CONF_NAME, CONF_URL, @@ -38,7 +40,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.util import get_local_ip import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -142,7 +143,7 @@ async def async_setup_platform( async with hass.data[DLNA_DMR_DATA]["lock"]: server_host = config.get(CONF_LISTEN_IP) if server_host is None: - server_host = get_local_ip() + server_host = await async_get_source_ip(hass, PUBLIC_TARGET_IP) server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) event_handler = await async_start_event_handler( diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index b1ec63e0ce9..10eb6553dbd 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -16,13 +16,14 @@ from fritzconnection.core.exceptions import ( import slugify as unicode_slug import xmltodict +from homeassistant.components.network import async_get_source_ip from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import get_local_ip, slugify +from homeassistant.util import slugify from .common import ( FritzBoxBaseEntity, @@ -161,7 +162,7 @@ def deflection_entities_list( def port_entities_list( - fritzbox_tools: FritzBoxTools, device_friendly_name: str + fritzbox_tools: FritzBoxTools, device_friendly_name: str, local_ip: str ) -> list[FritzBoxPortSwitch]: """Get list of port forwarding entities.""" @@ -194,7 +195,6 @@ def port_entities_list( port_forwards_count, ) - local_ip = get_local_ip() _LOGGER.debug("IP source for %s is %s", fritzbox_tools.host, local_ip) for i in range(port_forwards_count): @@ -290,12 +290,15 @@ def profile_entities_list( def all_entities_list( - fritzbox_tools: FritzBoxTools, device_friendly_name: str, data_fritz: FritzData + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + data_fritz: FritzData, + local_ip: str, ) -> list[Entity]: """Get a list of all entities.""" return [ *deflection_entities_list(fritzbox_tools, device_friendly_name), - *port_entities_list(fritzbox_tools, device_friendly_name), + *port_entities_list(fritzbox_tools, device_friendly_name, local_ip), *wifi_entities_list(fritzbox_tools, device_friendly_name), *profile_entities_list(fritzbox_tools, data_fritz), ] @@ -311,8 +314,12 @@ async def async_setup_entry( _LOGGER.debug("Fritzbox services: %s", fritzbox_tools.connection.services) + local_ip = await async_get_source_ip( + fritzbox_tools.hass, target_ip=fritzbox_tools.host + ) + entities_list = await hass.async_add_executor_job( - all_entities_list, fritzbox_tools, entry.title, data_fritz + all_entities_list, fritzbox_tools, entry.title, data_fritz, local_ip ) async_add_entities(entities_list) diff --git a/homeassistant/components/local_ip/manifest.json b/homeassistant/components/local_ip/manifest.json index f7e245aac05..cec6e094f50 100644 --- a/homeassistant/components/local_ip/manifest.json +++ b/homeassistant/components/local_ip/manifest.json @@ -3,6 +3,7 @@ "name": "Local IP Address", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_ip", + "dependencies": ["network"], "codeowners": ["@issacg"], "iot_class": "local_polling" } diff --git a/homeassistant/components/local_ip/sensor.py b/homeassistant/components/local_ip/sensor.py index c7bc53caa69..661ef88e641 100644 --- a/homeassistant/components/local_ip/sensor.py +++ b/homeassistant/components/local_ip/sensor.py @@ -1,11 +1,12 @@ """Sensor platform for local_ip.""" +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import get_local_ip from .const import DOMAIN, SENSOR @@ -30,6 +31,8 @@ class IPSensor(SensorEntity): """Initialize the sensor.""" self._attr_name = name - def update(self) -> None: + async def async_update(self) -> None: """Fetch new state data for the sensor.""" - self._attr_state = get_local_ip() + self._attr_state = await async_get_source_ip( + self.hass, target_ip=PUBLIC_TARGET_IP + ) diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 6f11b0947d8..48903d145e7 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -33,7 +33,7 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: @bind_hass -async def async_get_source_ip(hass: HomeAssistant, target_ip: str) -> str | None: +async def async_get_source_ip(hass: HomeAssistant, target_ip: str) -> str: """Get the source ip for a target ip.""" adapters = await async_get_adapters(hass) all_ipv4s = [] diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index ff69f026fef..8b695a52e13 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -16,7 +16,7 @@ ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] MDNS_TARGET_IP: Final = "224.0.0.251" - +PUBLIC_TARGET_IP: Final = "8.8.8.8" NETWORK_CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 5788ec1b3ef..6ad7111ae12 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -6,12 +6,13 @@ import voluptuous as vol from homeassistant import config_entries 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.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from homeassistant.util import get_local_ip from .const import ( CONF_LOCAL_IP, @@ -63,7 +64,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): _LOGGER.debug("async_setup, config: %s", config) conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] conf = config.get(DOMAIN, conf_default) - local_ip = await hass.async_add_executor_job(get_local_ip) + local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) hass.data[DOMAIN] = { DOMAIN_CONFIG: conf, DOMAIN_DEVICES: {}, diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 810a53c9e28..41d50b4bae8 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", "requirements": ["async-upnp-client==0.19.1"], - "dependencies": ["ssdp"], + "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman"], "ssdp": [ { diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index d8d664b63c5..907ec680cb4 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -19,8 +19,9 @@ from zeroconf import ( ) from zeroconf.asyncio import AsyncServiceInfo -from homeassistant import config_entries, util +from homeassistant import config_entries from homeassistant.components import network +from homeassistant.components.network import async_get_source_ip from homeassistant.components.network.models import Adapter from homeassistant.const import ( EVENT_HOMEASSISTANT_START, @@ -222,7 +223,7 @@ async def _async_register_hass_zc_service( # Set old base URL based on external or internal params["base_url"] = params["external_url"] or params["internal_url"] - host_ip = util.get_local_ip() + host_ip = await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP) try: host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip) diff --git a/tests/components/local_ip/test_init.py b/tests/components/local_ip/test_init.py index a7ebfba28e2..be1e689ca16 100644 --- a/tests/components/local_ip/test_init.py +++ b/tests/components/local_ip/test_init.py @@ -1,6 +1,7 @@ """Tests for the local_ip component.""" from homeassistant.components.local_ip import DOMAIN -from homeassistant.util import get_local_ip +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.zeroconf import MDNS_TARGET_IP from tests.common import MockConfigEntry @@ -13,7 +14,7 @@ async def test_basic_setup(hass): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - local_ip = await hass.async_add_executor_job(get_local_ip) + local_ip = await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP) state = hass.states.get(f"sensor.{DOMAIN}") assert state assert state.state == local_ip From 0b71055989d7579155200e834ef057efbcf855f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Jul 2021 12:11:10 -0700 Subject: [PATCH 110/112] Do not automatically add title to strings.json (#53350) --- script/scaffold/generate.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 7ebc364d7ee..122d8570dc1 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -113,7 +113,6 @@ def _custom_tasks(template, info: Info) -> None: elif template == "config_flow": info.update_manifest(config_flow=True) info.update_strings( - title=info.name, config={ "step": { "user": { @@ -138,7 +137,6 @@ def _custom_tasks(template, info: Info) -> None: elif template == "config_flow_discovery": info.update_manifest(config_flow=True) info.update_strings( - title=info.name, config={ "step": { "confirm": { @@ -155,7 +153,6 @@ def _custom_tasks(template, info: Info) -> None: elif template == "config_flow_oauth2": info.update_manifest(config_flow=True, dependencies=["http"]) info.update_strings( - title=info.name, config={ "step": { "pick_implementation": { From 84dc6af7604c355554f1eab4d0d2517bc61b9c16 Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Thu, 22 Jul 2021 21:56:38 +0200 Subject: [PATCH 111/112] Update to PyVicare 1.0 (#53281) --- homeassistant/components/vicare/__init__.py | 5 +- .../components/vicare/binary_sensor.py | 38 +++--------- homeassistant/components/vicare/climate.py | 58 +++++++++++-------- homeassistant/components/vicare/manifest.json | 2 +- homeassistant/components/vicare/sensor.py | 10 +++- .../components/vicare/water_heater.py | 32 +++++----- requirements_all.txt | 2 +- 7 files changed, 70 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 88c4ce33a86..f3ffd7e1db6 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -9,6 +9,7 @@ from PyViCare.PyViCareHeatPump import HeatPump import voluptuous as vol from homeassistant.const import ( + CONF_CLIENT_ID, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,7 +24,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = ["climate", "sensor", "binary_sensor", "water_heater"] DOMAIN = "vicare" -PYVICARE_ERROR = "error" VICARE_API = "api" VICARE_NAME = "name" VICARE_HEATING_TYPE = "heating_type" @@ -48,6 +48,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=60): vol.All( cv.time_period, lambda value: value.total_seconds() ), @@ -71,7 +72,7 @@ def setup(hass, config): params["circuit"] = conf[CONF_CIRCUIT] params["cacheDuration"] = conf.get(CONF_SCAN_INTERVAL) - + params["client_id"] = conf.get(CONF_CLIENT_ID) heating_type = conf[CONF_HEATING_TYPE] try: diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 823c4f1ba1b..0c98d22e9ae 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,6 +1,8 @@ """Viessmann ViCare sensor device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests from homeassistant.components.binary_sensor import ( @@ -11,7 +13,6 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from . import ( DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, @@ -29,10 +30,6 @@ SENSOR_BURNER_ACTIVE = "burner_active" # heatpump sensors SENSOR_COMPRESSOR_ACTIVE = "compressor_active" -SENSOR_HEATINGROD_OVERALL = "heatingrod_overall" -SENSOR_HEATINGROD_LEVEL1 = "heatingrod_level1" -SENSOR_HEATINGROD_LEVEL2 = "heatingrod_level2" -SENSOR_HEATINGROD_LEVEL3 = "heatingrod_level3" SENSOR_TYPES = { SENSOR_CIRCULATION_PUMP_ACTIVE: { @@ -52,26 +49,6 @@ SENSOR_TYPES = { CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, CONF_GETTER: lambda api: api.getCompressorActive(), }, - SENSOR_HEATINGROD_OVERALL: { - CONF_NAME: "Heating rod overall", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusOverall(), - }, - SENSOR_HEATINGROD_LEVEL1: { - CONF_NAME: "Heating rod level 1", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusLevel1(), - }, - SENSOR_HEATINGROD_LEVEL2: { - CONF_NAME: "Heating rod level 2", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusLevel2(), - }, - SENSOR_HEATINGROD_LEVEL3: { - CONF_NAME: "Heating rod level 3", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusLevel3(), - }, } SENSORS_GENERIC = [SENSOR_CIRCULATION_PUMP_ACTIVE] @@ -80,10 +57,6 @@ SENSORS_BY_HEATINGTYPE = { HeatingType.gas: [SENSOR_BURNER_ACTIVE], HeatingType.heatpump: [ SENSOR_COMPRESSOR_ACTIVE, - SENSOR_HEATINGROD_OVERALL, - SENSOR_HEATINGROD_LEVEL1, - SENSOR_HEATINGROD_LEVEL2, - SENSOR_HEATINGROD_LEVEL3, ], HeatingType.fuelcell: [SENSOR_BURNER_ACTIVE], } @@ -126,7 +99,7 @@ class ViCareBinarySensor(BinarySensorEntity): @property def available(self): """Return True if entity is available.""" - return self._state is not None and self._state != PYVICARE_ERROR + return self._state is not None @property def unique_id(self): @@ -151,8 +124,11 @@ class ViCareBinarySensor(BinarySensorEntity): def update(self): """Update state of sensor.""" try: - self._state = self._sensor[CONF_GETTER](self._api) + with suppress(PyViCareNotSupportedFeatureError): + self._state = self._sensor[CONF_GETTER](self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index cfbfa1ddec6..2822d048152 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -1,6 +1,8 @@ """Viessmann ViCare climate device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests import voluptuous as vol @@ -21,7 +23,6 @@ from homeassistant.helpers import entity_platform from . import ( DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, @@ -136,47 +137,58 @@ class ViCareClimate(ClimateEntity): def update(self): """Let HA know there has been an update from the ViCare API.""" try: - _room_temperature = self._api.getRoomTemperature() - _supply_temperature = self._api.getSupplyTemperature() - if _room_temperature is not None and _room_temperature != PYVICARE_ERROR: + _room_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + _room_temperature = self._api.getRoomTemperature() + + _supply_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + _supply_temperature = self._api.getSupplyTemperature() + + if _room_temperature is not None: self._current_temperature = _room_temperature - elif _supply_temperature != PYVICARE_ERROR: + elif _supply_temperature is not None: self._current_temperature = _supply_temperature else: self._current_temperature = None - self._current_program = self._api.getActiveProgram() - # The getCurrentDesiredTemperature call can yield 'error' (str) when the system is in standby - desired_temperature = self._api.getCurrentDesiredTemperature() - if desired_temperature == PYVICARE_ERROR: - desired_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + self._current_program = self._api.getActiveProgram() - self._target_temperature = desired_temperature + with suppress(PyViCareNotSupportedFeatureError): + self._target_temperature = self._api.getCurrentDesiredTemperature() - self._current_mode = self._api.getActiveMode() + with suppress(PyViCareNotSupportedFeatureError): + self._current_mode = self._api.getActiveMode() # Update the generic device attributes self._attributes = {} + self._attributes["room_temperature"] = _room_temperature self._attributes["active_vicare_program"] = self._current_program self._attributes["active_vicare_mode"] = self._current_mode - self._attributes["heating_curve_slope"] = self._api.getHeatingCurveSlope() - self._attributes["heating_curve_shift"] = self._api.getHeatingCurveShift() - self._attributes[ - "month_since_last_service" - ] = self._api.getMonthSinceLastService() - self._attributes["date_last_service"] = self._api.getLastServiceDate() - self._attributes["error_history"] = self._api.getErrorHistory() - self._attributes["active_error"] = self._api.getActiveError() + + with suppress(PyViCareNotSupportedFeatureError): + self._attributes[ + "heating_curve_slope" + ] = self._api.getHeatingCurveSlope() + + with suppress(PyViCareNotSupportedFeatureError): + self._attributes[ + "heating_curve_shift" + ] = self._api.getHeatingCurveShift() # Update the specific device attributes if self._heating_type == HeatingType.gas: - self._current_action = self._api.getBurnerActive() - + with suppress(PyViCareNotSupportedFeatureError): + self._current_action = self._api.getBurnerActive() elif self._heating_type == HeatingType.heatpump: - self._current_action = self._api.getCompressorActive() + with suppress(PyViCareNotSupportedFeatureError): + self._current_action = self._api.getCompressorActive() except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except ValueError: _LOGGER.error("Unable to decode data from ViCare server") diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 400618c3e85..88e9a1e4e4b 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -3,6 +3,6 @@ "name": "Viessmann ViCare", "documentation": "https://www.home-assistant.io/integrations/vicare", "codeowners": ["@oischinger"], - "requirements": ["PyViCare==0.2.5"], + "requirements": ["PyViCare==1.0.0"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 7d224de3835..4f7ab9df985 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1,6 +1,8 @@ """Viessmann ViCare sensor device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests from homeassistant.components.sensor import SensorEntity @@ -21,7 +23,6 @@ from homeassistant.const import ( from . import ( DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, @@ -350,7 +351,7 @@ class ViCareSensor(SensorEntity): @property def available(self): """Return True if entity is available.""" - return self._state is not None and self._state != PYVICARE_ERROR + return self._state is not None @property def unique_id(self): @@ -385,8 +386,11 @@ class ViCareSensor(SensorEntity): def update(self): """Update state of sensor.""" try: - self._state = self._sensor[CONF_GETTER](self._api) + with suppress(PyViCareNotSupportedFeatureError): + self._state = self._sensor[CONF_GETTER](self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index cbecf7fdaf2..af373c6ee6e 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,6 +1,8 @@ """Viessmann ViCare water_heater device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests from homeassistant.components.water_heater import ( @@ -9,13 +11,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS -from . import ( - DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, - VICARE_API, - VICARE_HEATING_TYPE, - VICARE_NAME, -) +from . import DOMAIN as VICARE_DOMAIN, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -81,19 +77,23 @@ class ViCareWater(WaterHeaterEntity): def update(self): """Let HA know there has been an update from the ViCare API.""" try: - current_temperature = self._api.getDomesticHotWaterStorageTemperature() - if current_temperature != PYVICARE_ERROR: - self._current_temperature = current_temperature - else: - self._current_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + self._current_temperature = ( + self._api.getDomesticHotWaterStorageTemperature() + ) - self._target_temperature = ( - self._api.getDomesticHotWaterConfiguredTemperature() - ) + with suppress(PyViCareNotSupportedFeatureError): + self._target_temperature = ( + self._api.getDomesticHotWaterConfiguredTemperature() + ) + + with suppress(PyViCareNotSupportedFeatureError): + self._current_mode = self._api.getActiveMode() - self._current_mode = self._api.getActiveMode() except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except ValueError: _LOGGER.error("Unable to decode data from ViCare server") diff --git a/requirements_all.txt b/requirements_all.txt index 9c9e88f22ae..f6ba5dc03df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -61,7 +61,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.5.0 # homeassistant.components.vicare -PyViCare==0.2.5 +PyViCare==1.0.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 From 12503d548b78f2b93745c6a396a0d0e39cb18ea3 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 22 Jul 2021 16:40:32 -0400 Subject: [PATCH 112/112] Use entity class attributes for canary (#53333) Co-authored-by: Franck Nijhof --- .../components/canary/alarm_control_panel.py | 21 ++----- homeassistant/components/canary/camera.py | 36 +++--------- homeassistant/components/canary/sensor.py | 56 ++++--------------- 3 files changed, 25 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 4e29c40f49f..4d29d4893e7 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -52,6 +52,9 @@ class CanaryAlarm(CoordinatorEntity, AlarmControlPanelEntity): """Representation of a Canary alarm control panel.""" coordinator: CanaryDataUpdateCoordinator + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) def __init__( self, coordinator: CanaryDataUpdateCoordinator, location: Location @@ -59,23 +62,14 @@ class CanaryAlarm(CoordinatorEntity, AlarmControlPanelEntity): """Initialize a Canary security camera.""" super().__init__(coordinator) self._location_id: str = location.location_id - self._location_name: str = location.name + self._attr_name = location.name + self._attr_unique_id = str(self._location_id) @property def location(self) -> Location: """Return information about the location.""" return self.coordinator.data["locations"][self._location_id] - @property - def name(self) -> str: - """Return the name of the alarm.""" - return self._location_name - - @property - def unique_id(self) -> str: - """Return the unique ID of the alarm.""" - return str(self._location_id) - @property def state(self) -> str | None: """Return the state of the device.""" @@ -92,11 +86,6 @@ class CanaryAlarm(CoordinatorEntity, AlarmControlPanelEntity): return None - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index b1725945db2..2699ba1f640 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -21,7 +21,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import Throttle @@ -30,7 +29,6 @@ from .const import ( CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, DOMAIN, MANUFACTURER, ) @@ -73,7 +71,6 @@ async def async_setup_entry( coordinator, location_id, device, - DEFAULT_TIMEOUT, ffmpeg_arguments, ) ) @@ -92,7 +89,6 @@ class CanaryCamera(CoordinatorEntity, Camera): coordinator: CanaryDataUpdateCoordinator, location_id: str, device: Device, - timeout: int, ffmpeg_args: str, ) -> None: """Initialize a Canary security camera.""" @@ -102,37 +98,21 @@ class CanaryCamera(CoordinatorEntity, Camera): self._ffmpeg_arguments = ffmpeg_args self._location_id = location_id self._device = device - self._device_id: str = device.device_id - self._device_name: str = device.name - self._device_type_name = device.device_type["name"] - self._timeout = timeout self._live_stream_session: LiveStreamSession | None = None + self._attr_name = device.name + self._attr_unique_id = str(device.device_id) + self._attr_device_info = { + "identifiers": {(DOMAIN, str(device.device_id))}, + "name": device.name, + "model": device.device_type["name"], + "manufacturer": MANUFACTURER, + } @property def location(self) -> Location: """Return information about the location.""" return self.coordinator.data["locations"][self._location_id] - @property - def name(self) -> str: - """Return the name of this device.""" - return self._device_name - - @property - def unique_id(self) -> str: - """Return the unique ID of this camera.""" - return str(self._device_id) - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, str(self._device_id))}, - "name": self._device_name, - "model": self._device_type_name, - "manufacturer": MANUFACTURER, - } - @property def is_recording(self) -> bool: """Return true if the device is recording.""" diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 91dc3bad5eb..5c92f0089f2 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -97,11 +96,9 @@ class CanarySensor(CoordinatorEntity, SensorEntity): super().__init__(coordinator) self._sensor_type = sensor_type self._device_id = device.device_id - self._device_name = device.name - self._device_type_name = device.device_type["name"] sensor_type_name = sensor_type[0].replace("_", " ").title() - self._name = f"{location.name} {device.name} {sensor_type_name}" + self._attr_name = f"{location.name} {device.name} {sensor_type_name}" canary_sensor_type = None if self._sensor_type[0] == "air_quality": @@ -116,6 +113,17 @@ 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))}, + "name": device.name, + "model": device.device_type["name"], + "manufacturer": MANUFACTURER, + } + self._attr_unit_of_measurement = sensor_type[1] + self._attr_device_class = sensor_type[3] + self._attr_icon = sensor_type[2] @property def reading(self) -> float | None: @@ -136,46 +144,6 @@ class CanarySensor(CoordinatorEntity, SensorEntity): return None - @property - def name(self) -> str: - """Return the name of the Canary sensor.""" - return self._name - - @property - def state(self) -> float | None: - """Return the state of the sensor.""" - return self.reading - - @property - def unique_id(self) -> str: - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._sensor_type[0]}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, str(self._device_id))}, - "name": self._device_name, - "model": self._device_type_name, - "manufacturer": MANUFACTURER, - } - - @property - def unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - return self._sensor_type[1] - - @property - def device_class(self) -> str | None: - """Device class for the sensor.""" - return self._sensor_type[3] - - @property - def icon(self) -> str | None: - """Icon for the sensor.""" - return self._sensor_type[2] - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes."""