diff --git a/.coveragerc b/.coveragerc index d313da55dd8..4040da2751a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -226,6 +226,7 @@ omit = homeassistant/components/dublin_bus_transport/sensor.py homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py + homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* homeassistant/components/ebox/sensor.py @@ -1438,7 +1439,6 @@ omit = homeassistant/components/xbox/media_player.py homeassistant/components/xbox/remote.py homeassistant/components/xbox/sensor.py - homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py homeassistant/components/xiaomi/camera.py homeassistant/components/xiaomi_aqara/__init__.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8464b1a299f..d5cb7412209 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,9 @@ env: # - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023) # 10.10 is the latest short-term-support # - 10.10.3 is the latest (as of 6 Feb 2023) - MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3']" + # mysql 8.0.32 does not always behave the same as MariaDB + # and some queries that work on MariaDB do not work on MySQL + MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']" # 12 is the oldest supported version # - 12.14 is the latest (as of 9 Feb 2023) # 15 is the latest version @@ -434,6 +436,7 @@ jobs: shell: bash run: | . venv/bin/activate + shopt -s globstar pre-commit run --hook-stage manual prettier --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} - name: Register check executables problem matcher @@ -1091,6 +1094,7 @@ jobs: needs: - info - pytest + timeout-minutes: 10 steps: - name: Check out code from GitHub uses: actions/checkout@v3.5.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd196f19db3..88bb4a703a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.256 + rev: v0.0.260 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: diff --git a/.strict-typing b/.strict-typing index 533d5239cab..84915e3f1b3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -137,6 +137,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_hardware.* diff --git a/CODEOWNERS b/CODEOWNERS index 0e918caadea..906787e0452 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -80,6 +80,8 @@ build.json @home-assistant/supervisor /tests/components/android_ip_webcam/ @engrbm87 /homeassistant/components/androidtv/ @JeffLIrion @ollo69 /tests/components/androidtv/ @JeffLIrion @ollo69 +/homeassistant/components/androidtv_remote/ @tronikos +/tests/components/androidtv_remote/ @tronikos /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex /homeassistant/components/apache_kafka/ @bachya @@ -215,8 +217,6 @@ build.json @home-assistant/supervisor /tests/components/conversation/ @home-assistant/core @synesthesiam /homeassistant/components/coolmaster/ @OnFreund /tests/components/coolmaster/ @OnFreund -/homeassistant/components/coronavirus/ @home-assistant/core -/tests/components/coronavirus/ @home-assistant/core /homeassistant/components/counter/ @fabaff /tests/components/counter/ @fabaff /homeassistant/components/cover/ @home-assistant/core @@ -228,8 +228,6 @@ build.json @home-assistant/supervisor /homeassistant/components/cups/ @fabaff /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike -/homeassistant/components/darksky/ @fabaff -/tests/components/darksky/ @fabaff /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 @@ -283,7 +281,7 @@ build.json @home-assistant/supervisor /tests/components/dsmr_reader/ @depl0y @glodenox /homeassistant/components/dunehd/ @bieniu /tests/components/dunehd/ @bieniu -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k @@ -979,6 +977,8 @@ build.json @home-assistant/supervisor /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core /homeassistant/components/repetier/ @MTrab @ShadowBr0ther +/homeassistant/components/rest/ @epenet +/tests/components/rest/ @epenet /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 @@ -1157,8 +1157,8 @@ build.json @home-assistant/supervisor /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter /tests/components/stream/ @hunterjm @uvjustin @allenporter -/homeassistant/components/stt/ @pvizeli -/tests/components/stt/ @pvizeli +/homeassistant/components/stt/ @home-assistant/core @pvizeli +/tests/components/stt/ @home-assistant/core @pvizeli /homeassistant/components/subaru/ @G-Two /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii @@ -1303,8 +1303,6 @@ build.json @home-assistant/supervisor /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey -/homeassistant/components/vicare/ @oischinger -/tests/components/vicare/ @oischinger /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel @@ -1367,7 +1365,6 @@ build.json @home-assistant/supervisor /tests/components/ws66i/ @ssaenger /homeassistant/components/xbox/ @hunterjm /tests/components/xbox/ @hunterjm -/homeassistant/components/xbox_live/ @MartinHjelmare /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 445ff35793c..d98680c70d4 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -239,6 +239,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: # Load the registries and cache the result of platform.uname().processor entity.async_setup(hass) + template.async_setup(hass) await asyncio.gather( area_registry.async_load(hass), device_registry.async_load(hass), diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index d28932082a6..9da24e76f19 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -10,7 +10,6 @@ "microsoft_face", "microsoft", "msteams", - "xbox", - "xbox_live" + "xbox" ] } diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 89af284f873..4a015728d6f 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -17,7 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER @@ -116,11 +115,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async with timeout(10): current = await self.accuweather.async_get_current_conditions() forecast = ( - await self.accuweather.async_get_forecast( - metric=self.hass.config.units is METRIC_SYSTEM - ) - if self.forecast - else {} + await self.accuweather.async_get_forecast() if self.forecast else {} ) except ( ApiError, diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1336e31f415..87bc8eaef89 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -20,7 +20,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ) -API_IMPERIAL: Final = "Imperial" API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" ATTR_CATEGORY: Final = "Category" diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 6cb0b45418c..4d58919947e 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -26,11 +26,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( - API_IMPERIAL, API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, @@ -51,7 +49,7 @@ PARALLEL_UPDATES = 1 class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" - value_fn: Callable[[dict[str, Any], str], StateType] + value_fn: Callable[[dict[str, Any]], StateType] @dataclass @@ -61,8 +59,6 @@ class AccuWeatherSensorDescription( """Class describing AccuWeather sensor entities.""" attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {} - metric_unit: str | None = None - us_customary_unit: str | None = None FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( @@ -72,7 +68,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover day", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="CloudCoverNight", @@ -80,7 +76,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover night", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Grass", @@ -88,7 +84,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Grass pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -96,7 +92,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-partly-cloudy", name="Hours of sun", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data, _: cast(float, data), + value_fn=lambda data: cast(float, data), ), AccuWeatherSensorDescription( key="Mold", @@ -104,7 +100,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Mold pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -112,7 +108,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:vector-triangle", name="Ozone", entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -121,56 +117,52 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Ragweed pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature max", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature min", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade max", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade min", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", name="Thunderstorm probability day", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", name="Thunderstorm probability night", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Tree", @@ -178,7 +170,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Tree pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -186,7 +178,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-sunny", name="UV index", native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -194,9 +186,8 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust day", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( @@ -204,27 +195,24 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust night", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind day", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, name="Wind night", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), ) @@ -236,9 +224,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Apparent temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Ceiling", @@ -246,9 +233,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-fog", name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfLength.METERS, - us_customary_unit=UnitOfLength.FEET, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), suggested_display_precision=0, ), AccuWeatherSensorDescription( @@ -258,7 +244,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="DewPoint", @@ -266,18 +252,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Dew point", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperature", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", @@ -285,18 +269,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="RealFeel temperature shade", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, name="Precipitation", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), attr_fn=lambda data: {"type": data["PrecipitationType"]}, ), AccuWeatherSensorDescription( @@ -306,7 +288,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Pressure tendency", options=["falling", "rising", "steady"], translation_key="pressure_tendency", - value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(), + value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), ), AccuWeatherSensorDescription( key="UVIndex", @@ -314,7 +296,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="UV index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, ), AccuWeatherSensorDescription( @@ -323,9 +305,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wet bulb temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindChillTemperature", @@ -333,18 +314,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind chill temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, name="Wind", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindGust", @@ -352,9 +331,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind gust", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), ) @@ -374,7 +352,7 @@ async def async_setup_entry( # Some air quality/allergy sensors are only available for certain # locations. sensors.extend( - AccuWeatherForecastSensor(coordinator, description, forecast_day=day) + AccuWeatherSensor(coordinator, description, forecast_day=day) for day in range(MAX_FORECAST_DAYS + 1) for description in FORECAST_SENSOR_TYPES if description.key in coordinator.data[ATTR_FORECAST][0] @@ -413,34 +391,27 @@ class AccuWeatherSensor( self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) - self._attr_native_unit_of_measurement = description.native_unit_of_measurement - if self.coordinator.hass.config.units is METRIC_SYSTEM: - self._unit_system = API_METRIC - if metric_unit := description.metric_unit: - self._attr_native_unit_of_measurement = metric_unit - else: - self._unit_system = API_IMPERIAL - if us_customary_unit := description.us_customary_unit: - self._attr_native_unit_of_measurement = us_customary_unit self._attr_device_info = coordinator.device_info - if forecast_day is not None: - self.forecast_day = forecast_day + self.forecast_day = forecast_day @property def native_value(self) -> StateType: """Return the state.""" - return self.entity_description.value_fn(self._sensor_data, self._unit_system) + return self.entity_description.value_fn(self._sensor_data) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" + if self.forecast_day is not None: + return self.entity_description.attr_fn(self._sensor_data) + return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key + self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() @@ -458,20 +429,3 @@ def _get_sensor_data( return sensors["PrecipitationSummary"]["PastHour"] return sensors[kind] - - -class AccuWeatherForecastSensor(AccuWeatherSensor): - """Define an AccuWeather forecast entity.""" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return self.entity_description.attr_fn(self._sensor_data) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle data update.""" - self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key, self.forecast_day - ) - self.async_write_ha_state() diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 5c5ba303ad5..76a5d62a107 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator -from .const import ( - API_IMPERIAL, - API_METRIC, - ATTR_FORECAST, - ATTRIBUTION, - CONDITION_CLASSES, - DOMAIN, -) +from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN PARALLEL_UPDATES = 1 @@ -66,20 +58,11 @@ class AccuWeatherEntity( # Coordinator data is used also for sensors which don't have units automatically # converted, hence the weather entity's native units follow the configured unit # system - if coordinator.hass.config.units is METRIC_SYSTEM: - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_visibility_unit = UnitOfLength.KILOMETERS - self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._unit_system = API_METRIC - else: - self._unit_system = API_IMPERIAL - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES - self._attr_native_pressure_unit = UnitOfPressure.INHG - self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT - self._attr_native_visibility_unit = UnitOfLength.MILES - self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + self._attr_native_pressure_unit = UnitOfPressure.HPA + self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_native_visibility_unit = UnitOfLength.KILOMETERS + self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info @@ -99,16 +82,12 @@ class AccuWeatherEntity( @property def native_temperature(self) -> float: """Return the temperature.""" - return cast( - float, self.coordinator.data["Temperature"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast( - float, self.coordinator.data["Pressure"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) @property def humidity(self) -> int: @@ -118,9 +97,7 @@ class AccuWeatherEntity( @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast( - float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) @property def wind_bearing(self) -> int: @@ -130,9 +107,7 @@ class AccuWeatherEntity( @property def native_visibility(self) -> float: """Return the visibility.""" - return cast( - float, self.coordinator.data["Visibility"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) @property def ozone(self) -> int | None: diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 53a41994fc6..a8f0e9c884f 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -5,6 +5,8 @@ import logging from typing import Any from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -32,18 +34,10 @@ ADVANTAGE_AIR_HVAC_MODES = { "cool": HVACMode.COOL, "vent": HVACMode.FAN_ONLY, "dry": HVACMode.DRY, - "myauto": HVACMode.AUTO, + "myauto": HVACMode.HEAT_COOL, } HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()} -AC_HVAC_MODES = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, -] - ADVANTAGE_AIR_FAN_MODES = { "autoAA": FAN_AUTO, "low": FAN_LOW, @@ -53,7 +47,14 @@ ADVANTAGE_AIR_FAN_MODES = { HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} -ZONE_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT_COOL] +ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled" +ADVANTAGE_AIR_MYZONE = "MyZone" +ADVANTAGE_AIR_MYAUTO = "MyAuto" +ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled" +ADVANTAGE_AIR_MYTEMP = "MyTemp" +ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" +ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" +ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" PARALLEL_UPDATES = 0 @@ -75,7 +76,7 @@ async def async_setup_entry( entities.append(AdvantageAirAC(instance, ac_key)) for zone_key, zone in ac_device["zones"].items(): # Only add zone climate control when zone is in temperature control - if zone["type"] != 0: + if zone["type"] > 0: entities.append(AdvantageAirZone(instance, ac_key, zone_key)) async_add_entities(entities) @@ -83,24 +84,56 @@ async def async_setup_entry( class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """AdvantageAir AC unit.""" + _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] - _attr_hvac_modes = AC_HVAC_MODES - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) def __init__(self, instance: dict[str, Any], ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) - if self._ac.get("myAutoModeEnabled"): - self._attr_hvac_modes = AC_HVAC_MODES + [HVACMode.AUTO] + + # Set supported features and HVAC modes based on current operating mode + if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): + # MyAuto + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.HEAT_COOL, + ] + elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): + # MyTemp + self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] + + else: + # MyZone + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] + + # Add "ezfan" mode if supported + if self._ac.get(ADVANTAGE_AIR_AUTOFAN): + self._attr_fan_modes += [FAN_AUTO] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the current target temperature.""" return self._ac["setTemp"] @@ -116,6 +149,16 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + @property + def target_temperature_high(self) -> float | None: + """Return the temperature cool mode is enabled.""" + return self._ac.get(ADVANTAGE_AIR_COOL_TARGET) + + @property + def target_temperature_low(self) -> float | None: + """Return the temperature heat mode is enabled.""" + return self._ac.get(ADVANTAGE_AIR_HEAT_TARGET) + async def async_turn_on(self) -> None: """Set the HVAC State to on.""" await self.aircon( @@ -166,27 +209,37 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" - temp = kwargs.get(ATTR_TEMPERATURE) - await self.aircon({self.ac_key: {"info": {"setTemp": temp}}}) + if ATTR_TEMPERATURE in kwargs: + await self.aircon( + {self.ac_key: {"info": {"setTemp": kwargs[ATTR_TEMPERATURE]}}} + ) + if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: + await self.aircon( + { + self.ac_key: { + "info": { + ADVANTAGE_AIR_COOL_TARGET: kwargs[ATTR_TARGET_TEMP_HIGH], + ADVANTAGE_AIR_HEAT_TARGET: kwargs[ATTR_TARGET_TEMP_LOW], + } + } + } + ) class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): - """AdvantageAir Zone control.""" + """AdvantageAir MyTemp Zone control.""" + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _attr_hvac_modes = ZONE_HVAC_MODES - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: """Initialize an AdvantageAir Zone control.""" super().__init__(instance, ac_key, zone_key) self._attr_name = self._zone["name"] - self._attr_unique_id = ( - f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' - ) @property def hvac_mode(self) -> HVACMode: @@ -196,7 +249,7 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): return HVACMode.OFF @property - def current_temperature(self) -> float: + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._zone["measuredTemp"] diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 754471c9d8b..53e15c651a7 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -68,7 +68,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, icon="mdi:air-filter", - name=ATTR_API_CAQI, + translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, attrs=lambda data: { @@ -80,7 +80,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - name="PM1.0", + translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +88,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - name="PM2.5", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +100,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - name=ATTR_API_PM10, + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +112,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - name=ATTR_API_HUMIDITY.capitalize(), + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - name=ATTR_API_PRESSURE.capitalize(), + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,14 +128,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - name=ATTR_API_TEMPERATURE.capitalize(), + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_CO, - name="Carbon monoxide", + translation_key="co", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -147,7 +147,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - name="Nitrogen dioxide", + translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - name="Sulphur dioxide", + translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - name="Ozone", + translation_key="o3", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 4f95f26afc0..50ebdd6d4dd 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -26,5 +26,42 @@ "requests_remaining": "Remaining allowed requests", "requests_per_day": "Allowed requests per day" } + }, + "entity": { + "sensor": { + "caqi": { + "name": "Common air quality index" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "co": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "no2": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "so2": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "o3": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + } + } } } diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 793b7879270..21be2e5d664 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) else: entry.version = version - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 5dd8f0fb2fd..f68ae3df114 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg.async_clear_config_entry(entry.entry_id) version = entry.version = 2 - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index bd07303df3e..ebd03651064 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["aioambient"], - "requirements": ["aioambient==2021.11.0"] + "requirements": ["aioambient==2023.04.0"] } diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index f8119e9c1b4..a423a628367 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL +from .const import CONF_STATION_ID, SCAN_INTERVAL _LOGGER: Final = logging.getLogger(__name__) @@ -54,6 +54,8 @@ async def async_setup_platform( class AmpioSmogQuality(AirQualityEntity): """Implementation of an Ampio Smog air quality entity.""" + _attr_attribution = "Data provided by Ampio" + def __init__( self, api: AmpioSmogMapData, station_id: str, name: str | None ) -> None: @@ -82,11 +84,6 @@ class AmpioSmogQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._ampio.api.pm10 # type: ignore[no-any-return] - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self) -> None: """Get the latest data from the AmpioMap API.""" await self._ampio.async_update() diff --git a/homeassistant/components/ampio/const.py b/homeassistant/components/ampio/const.py index 3162308ff41..b1a13ce9414 100644 --- a/homeassistant/components/ampio/const.py +++ b/homeassistant/components/ampio/const.py @@ -2,6 +2,5 @@ from datetime import timedelta from typing import Final -ATTRIBUTION: Final = "Data provided by Ampio" CONF_STATION_ID: Final = "station_id" SCAN_INTERVAL: Final = timedelta(minutes=10) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 7bf55480eb1..2542ed5177e 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -28,7 +28,7 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: # Send every day async_track_time_interval( - hass, analytics.send_analytics, INTERVAL, "analytics daily" + hass, analytics.send_analytics, INTERVAL, name="analytics daily" ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index d10b1161da6..4a1ad55e0b1 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV/Fire TV devices.""" +"""Support for functionality to interact with Android/Fire TV devices.""" from __future__ import annotations from collections.abc import Mapping @@ -135,11 +135,11 @@ async def async_connect_androidtv( if not aftv.available: # Determine the name that will be used for the device in the log if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: - device_name = "Android TV device" + device_name = "Android device" elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: device_name = "Fire TV device" else: - device_name = "Android TV / Fire TV device" + device_name = "Android / Fire TV device" error_message = f"Could not connect to {device_name} at {address} {adb_log}" return None, error_message @@ -148,7 +148,7 @@ async def async_connect_androidtv( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Android TV platform.""" + """Set up Android Debug Bridge platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) if CONF_ADB_SERVER_IP not in entry.data: @@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(error_message) async def async_close_connection(event): - """Close Android TV connection on HA Stop.""" + """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() entry.async_on_unload( diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index bac5a9aec6c..7e2b1e85f39 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the Android TV integration.""" +"""Config flow to configure the Android Debug Bridge integration.""" from __future__ import annotations import logging @@ -114,13 +114,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_check_connection( self, user_input: dict[str, Any] ) -> tuple[str | None, str | None]: - """Attempt to connect the Android TV.""" + """Attempt to connect the Android device.""" try: aftv, error_message = await async_connect_androidtv(self.hass, user_input) except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Android TV at %s", user_input[CONF_HOST] + "Unknown error connecting with Android device at %s", + user_input[CONF_HOST], ) return RESULT_UNKNOWN, None @@ -130,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): dev_prop = aftv.device_properties _LOGGER.info( - "Android TV at %s: %s = %r, %s = %r", + "Android device at %s: %s = %r, %s = %r", user_input[CONF_HOST], PROP_ETHMAC, dev_prop.get(PROP_ETHMAC), @@ -184,7 +185,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlowWithConfigEntry): - """Handle an option flow for Android TV.""" + """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py index 7f1e1288519..17936421680 100644 --- a/homeassistant/components/androidtv/const.py +++ b/homeassistant/components/androidtv/const.py @@ -1,4 +1,4 @@ -"""Android TV component constants.""" +"""Android Debug Bridge component constants.""" DOMAIN = "androidtv" ANDROID_DEV = DOMAIN diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2de47c65ad3..f782db79879 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -1,6 +1,6 @@ { "domain": "androidtv", - "name": "Android TV", + "name": "Android Debug Bridge", "codeowners": ["@JeffLIrion", "@ollo69"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv", diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index fb01ffce77f..563b8f07b2a 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV / Fire TV devices.""" +"""Support for functionality to interact with Android / Fire TV devices.""" from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine @@ -87,7 +87,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Android TV entity.""" + """Set up the Android Debug Bridge entity.""" aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] device_class = aftv.DEVICE_CLASS device_type = ( @@ -201,7 +201,7 @@ def adb_decorator( class ADBDevice(MediaPlayerEntity): - """Representation of an Android TV or Fire TV device.""" + """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV @@ -214,7 +214,7 @@ class ADBDevice(MediaPlayerEntity): entry_id, entry_data, ): - """Initialize the Android TV / Fire TV device.""" + """Initialize the Android / Fire TV device.""" self.aftv = aftv self._attr_name = name self._attr_unique_id = unique_id @@ -384,7 +384,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def adb_command(self, command): - """Send an ADB command to an Android TV / Fire TV device.""" + """Send an ADB command to an Android / Fire TV device.""" if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return @@ -422,13 +422,13 @@ class ADBDevice(MediaPlayerEntity): persistent_notification.async_create( self.hass, msg, - title="Android TV", + title="Android Debug Bridge", ) _LOGGER.info("%s", msg) @adb_decorator() async def service_download(self, device_path, local_path): - """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + """Download a file from your Android / Fire TV device to your Home Assistant instance.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -437,7 +437,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def service_upload(self, device_path, local_path): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + """Upload a file from your Home Assistant instance to an Android / Fire TV device.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -446,7 +446,7 @@ class ADBDevice(MediaPlayerEntity): class AndroidTVDevice(ADBDevice): - """Representation of an Android TV device.""" + """Representation of an Android device.""" _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index fef06266e52..4482f50f3e2 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -1,8 +1,8 @@ -# Describes the format for available Android TV and Fire TV services +# Describes the format for available Android and Fire TV services adb_command: name: ADB command - description: Send an ADB command to an Android TV / Fire TV device. + description: Send an ADB command to an Android / Fire TV device. target: entity: integration: androidtv @@ -17,7 +17,7 @@ adb_command: text: download: name: Download - description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + description: Download a file from your Android / Fire TV device to your Home Assistant instance. target: entity: integration: androidtv @@ -25,7 +25,7 @@ download: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: @@ -39,7 +39,7 @@ download: text: upload: name: Upload - description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + description: Upload a file from your Home Assistant instance to an Android / Fire TV device. target: entity: integration: androidtv @@ -47,7 +47,7 @@ upload: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index 7a46228bd4e..e7d06a9f624 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -38,7 +38,7 @@ } }, "apps": { - "title": "Configure Android TV Apps", + "title": "Configure Android Apps", "description": "Configure application id {app_id}", "data": { "app_name": "Application Name", @@ -47,7 +47,7 @@ } }, "rules": { - "title": "Configure Android TV state detection rules", + "title": "Configure Android state detection rules", "description": "Configure detection rule for application id {rule_id}", "data": { "rule_id": "Application ID", diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py new file mode 100644 index 00000000000..fb275342cb0 --- /dev/null +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -0,0 +1,67 @@ +"""The Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import create_api + +PLATFORMS: list[Platform] = [Platform.REMOTE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Android TV Remote from a config entry.""" + + api = create_api(hass, entry.data[CONF_HOST]) + try: + await api.async_connect() + except InvalidAuth as exc: + # The Android TV is hard reset or the certificate and key files were deleted. + raise ConfigEntryAuthFailed from exc + except (CannotConnect, ConnectionClosed) as exc: + # The Android TV is network unreachable. Raise exception and let Home Assistant retry + # later. If device gets a new IP address the zeroconf flow will update the config. + raise ConfigEntryNotReady from exc + + def reauth_needed() -> None: + """Start a reauth flow if Android TV is hard reset while reconnecting.""" + entry.async_start_reauth(hass) + + # Start a task (canceled in disconnect) to keep reconnecting if device becomes + # network unreachable. If device gets a new IP address the zeroconf flow will + # update the config entry data and reload the config entry. + api.keep_reconnecting(reauth_needed) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + @callback + def on_hass_stop(event) -> None: + """Stop push updates when hass stops.""" + api.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + api.disconnect() + + return unload_ok diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py new file mode 100644 index 00000000000..24b64c622a9 --- /dev/null +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -0,0 +1,187 @@ +"""Config flow for Android TV Remote integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .helpers import create_api + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required("host"): str, + } +) + +STEP_PAIR_DATA_SCHEMA = vol.Schema( + { + vol.Required("pin"): str, + } +) + + +class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Android TV Remote.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a new AndroidTVRemoteConfigFlow.""" + self.api: AndroidTVRemote | None = None + self.reauth_entry: config_entries.ConfigEntry | None = None + self.host: str | None = None + self.name: str | None = None + self.mac: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self.host = user_input["host"] + assert self.host + api = create_api(self.hass, self.host) + try: + self.name, self.mac = await api.async_get_name_and_mac() + assert self.mac + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Likely invalid IP address or device is network unreachable. Stay + # in the user step allowing the user to enter a different host. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def _async_start_pair(self) -> FlowResult: + """Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen.""" + assert self.host + self.api = create_api(self.hass, self.host) + await self.api.async_generate_cert_if_missing() + await self.api.async_start_pairing() + return await self.async_step_pair() + + async def async_step_pair( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the pair step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + pin = user_input["pin"] + assert self.api + await self.api.async_finish_pairing(pin) + if self.reauth_entry: + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + assert self.name + return self.async_create_entry( + title=self.name, + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) + except InvalidAuth: + # Invalid PIN. Stay in the pair step allowing the user to enter + # a different PIN. + errors["base"] = "invalid_auth" + except ConnectionClosed: + # Either user canceled pairing on the Android TV itself (most common) + # or device doesn't respond to the specified host (device was unplugged, + # network was unplugged, or device got a new IP address). + # Attempt to pair again. + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device doesn't respond to the specified host. Abort. + # If we are in the user flow we could go back to the user step to allow + # them to enter a new IP address but we cannot do that for the zeroconf + # flow. Simpler to abort for both flows. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="pair", + data_schema=STEP_PAIR_DATA_SCHEMA, + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + self.host = discovery_info.host + self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") + self.mac = discovery_info.properties.get("bt") + assert self.mac + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.host, CONF_NAME: self.name} + ) + self.context.update({"title_placeholders": {CONF_NAME: self.name}}) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device became network unreachable after discovery. + # Abort and let discovery find it again later. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.name}, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.host = entry_data[CONF_HOST] + self.name = entry_data[CONF_NAME] + self.mac = entry_data[CONF_MAC] + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device is network unreachable. Abort. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py new file mode 100644 index 00000000000..82f494b81aa --- /dev/null +++ b/homeassistant/components/androidtv_remote/const.py @@ -0,0 +1,6 @@ +"""Constants for the Android TV Remote integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "androidtv_remote" diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py new file mode 100644 index 00000000000..28d16bf94fe --- /dev/null +++ b/homeassistant/components/androidtv_remote/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Android TV Remote.""" +from __future__ import annotations + +from typing import Any + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_HOST, CONF_MAC} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + return async_redact_data( + { + "api_device_info": api.device_info, + "config_entry_data": entry.data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py new file mode 100644 index 00000000000..0bc1f1b904f --- /dev/null +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -0,0 +1,18 @@ +"""Helper functions for Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR + + +def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote: + """Create an AndroidTVRemote instance.""" + return AndroidTVRemote( + client_name="Home Assistant", + certfile=hass.config.path(STORAGE_DIR, "androidtv_remote_cert.pem"), + keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"), + host=host, + loop=hass.loop, + ) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json new file mode 100644 index 00000000000..0e5d896a11f --- /dev/null +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "androidtv_remote", + "name": "Android TV Remote", + "codeowners": ["@tronikos"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/androidtv_remote", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["androidtvremote2"], + "quality_scale": "platinum", + "requirements": ["androidtvremote2==0.0.7"], + "zeroconf": ["_androidtvremote2._tcp.local."] +} diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py new file mode 100644 index 00000000000..1c68c92bc68 --- /dev/null +++ b/homeassistant/components/androidtv_remote/remote.py @@ -0,0 +1,154 @@ +"""Remote control support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +from typing import Any + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + RemoteEntity, + RemoteEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV remote entity based on a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) + + +class AndroidTVRemoteEntity(RemoteEntity): + """Representation of an Android TV Remote.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize device.""" + self._api = api + self._host = config_entry.data[CONF_HOST] + self._name = config_entry.data[CONF_NAME] + self._attr_unique_id = config_entry.unique_id + self._attr_supported_features = RemoteEntityFeature.ACTIVITY + self._attr_is_on = api.is_on + self._attr_current_activity = api.current_app + device_info = api.device_info + assert config_entry.unique_id + assert device_info + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, + identifiers={(DOMAIN, config_entry.unique_id)}, + name=self._name, + manufacturer=device_info["manufacturer"], + model=device_info["model"], + ) + + @callback + def is_on_updated(is_on: bool) -> None: + self._attr_is_on = is_on + self.async_write_ha_state() + + @callback + def current_app_updated(current_app: str) -> None: + self._attr_current_activity = current_app + self.async_write_ha_state() + + @callback + def is_available_updated(is_available: bool) -> None: + if is_available: + _LOGGER.info( + "Reconnected to %s at %s", + self._name, + self._host, + ) + else: + _LOGGER.warning( + "Disconnected from %s at %s", + self._name, + self._host, + ) + self._attr_available = is_available + self.async_write_ha_state() + + api.add_is_on_updated_callback(is_on_updated) + api.add_current_app_updated_callback(current_app_updated) + api.add_is_available_updated_callback(is_available_updated) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the Android TV on.""" + if not self.is_on: + self._send_key_command("POWER") + activity = kwargs.get(ATTR_ACTIVITY, "") + if activity: + self._send_launch_app_command(activity) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the Android TV off.""" + if self.is_on: + self._send_key_command("POWER") + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to one device.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS) + + for _ in range(num_repeats): + for single_command in command: + if hold_secs: + self._send_key_command(single_command, "START_LONG") + await asyncio.sleep(hold_secs) + self._send_key_command(single_command, "END_LONG") + else: + self._send_key_command(single_command, "SHORT") + await asyncio.sleep(delay_secs) + + def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None: + """Send a key press to Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_key_command(key_code, direction) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc + + def _send_launch_app_command(self, app_link: str) -> None: + """Launch an app on Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_launch_app_command(app_link) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json new file mode 100644 index 00000000000..983c604370b --- /dev/null +++ b/homeassistant/components/androidtv_remote/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "zeroconf_confirm": { + "title": "Discovered Android TV", + "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." + }, + "pair": { + "description": "Enter the pairing code displayed on the Android TV ({name}).", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need to pair again with the Android TV ({name})." + } + }, + "error": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "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%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 33521b3d066..f57d6c82b7f 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -75,7 +75,7 @@ class AuthorizationServer: token_url: str -class ApplicationCredentialsStorageCollection(collection.StorageCollection): +class ApplicationCredentialsStorageCollection(collection.DictStorageCollection): """Application credential collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -94,7 +94,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection): return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}" async def _update_data( - self, data: dict[str, str], update_data: dict[str, str] + self, item: dict[str, str], update_data: dict[str, str] ) -> dict[str, str]: """Return a new updated data object.""" raise ValueError("Updates not supported") @@ -144,7 +144,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = collection.IDManager() storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index e0982fe9fb2..62aef44a9ee 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -38,7 +38,10 @@ class AugustSubscriberMixin: def _async_setup_listeners(self): """Create interval and stop listeners.""" self._unsub_interval = async_track_time_interval( - self._hass, self._async_refresh, self._update_interval, "august refresh" + self._hass, + self._async_refresh, + self._update_interval, + name="august refresh", ) @callback diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index c4c05f1c515..65a425fa5c4 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 69df310bd55..f48a71a78c3 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -23,6 +23,8 @@ from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER +BUF_SIZE = 2**20 * 4 # 4MB + @dataclass class Backup: @@ -99,7 +101,7 @@ class BackupManager: backups: dict[str, Backup] = {} for backup_path in self.backup_dir.glob("*.tar"): try: - with tarfile.open(backup_path, "r:") as backup_file: + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: if data_file := backup_file.extractfile("./backup.json"): data = json_loads_object(data_file.read()) backup = Backup( @@ -227,7 +229,7 @@ class BackupManager: self.backup_dir.mkdir() with TemporaryDirectory() as tmp_dir, SecureTarFile( - tar_file_path, "w", gzip=False + tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) as tar_file: tmp_dir_path = Path(tmp_dir) save_json( @@ -237,6 +239,7 @@ class BackupManager: with SecureTarFile( tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), "w", + bufsize=BUF_SIZE, ) as core_tar: atomic_contents_add( tar_file=core_tar, diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 7b495912f5c..fb7e9eff780 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2022.2.0"] + "requirements": ["securetar==2023.3.0"] } diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index cf9a943b3df..b43b1fb6b7f 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -84,7 +84,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle zeroconf discovery.""" hass = self.hass - ipaddress = host_port(discovery_info.__dict__) + ipaddress = (discovery_info.host, discovery_info.port) self.device_config["host"] = discovery_info.host self.device_config["port"] = discovery_info.port diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index f1ffd6ecf58..d5e2ca0edbb 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -101,7 +101,7 @@ class BaseHaScanner(ABC): self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL, - f"{self.name} Bluetooth scanner watchdog", + name=f"{self.name} Bluetooth scanner watchdog", ) @hass_callback @@ -230,7 +230,7 @@ class BaseHaRemoteScanner(BaseHaScanner): self.hass, self._async_expire_devices, timedelta(seconds=30), - f"{self.name} Bluetooth scanner device expire", + name=f"{self.name} Bluetooth scanner device expire", ) cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history @@ -345,12 +345,27 @@ class BaseHaRemoteScanner(BaseHaScanner): tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, platform_data=(), ) - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) + if prev_discovery: + # + # Bleak updates the BLEDevice via create_or_update_device. + # We need to do the same to ensure integrations that already + # have the BLEDevice object get the updated details when they + # change. + # + # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 + # + device = prev_device + device.name = local_name + device.details = self._details | details + # pylint: disable-next=protected-access + device._rssi = rssi # deprecated, will be removed in newer bleak + else: + device = BLEDevice( + address=address, + name=local_name, + details=self._details | details, + rssi=rssi, # deprecated, will be removed in newer bleak + ) self._discovered_device_advertisement_datas[address] = ( device, advertisement_data, diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 7932520b454..75809f42e6c 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -276,7 +276,7 @@ class BluetoothManager: self.hass, self._async_check_unavailable, timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), - "Bluetooth manager unavailable tracking", + name="Bluetooth manager unavailable tracking", ) @hass_callback diff --git a/homeassistant/components/bluetooth/usage.py b/homeassistant/components/bluetooth/usage.py index b751559e7a4..d89f0b5b684 100644 --- a/homeassistant/components/bluetooth/usage.py +++ b/homeassistant/components/bluetooth/usage.py @@ -10,9 +10,10 @@ from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner ORIGINAL_BLEAK_CLIENT = bleak.BleakClient -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = ( +ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( bleak_retry_connector.BleakClientWithServiceCache ) +ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient def install_multiple_bleak_catcher() -> None: @@ -23,6 +24,7 @@ def install_multiple_bleak_catcher() -> None: bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment] bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc] bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache # type: ignore[misc,assignment] # noqa: E501 + bleak_retry_connector.BleakClient = HaBleakClientWrapper # type: ignore[misc] # noqa: E501 def uninstall_multiple_bleak_catcher() -> None: @@ -30,6 +32,9 @@ def uninstall_multiple_bleak_catcher() -> None: bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER # type: ignore[misc] bleak.BleakClient = ORIGINAL_BLEAK_CLIENT # type: ignore[misc] bleak_retry_connector.BleakClientWithServiceCache = ( # type: ignore[misc] + ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE + ) + bleak_retry_connector.BleakClient = ( # type: ignore[misc] ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT ) diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index d3d19f43087..3739734223e 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -70,6 +70,7 @@ async def async_setup_scanner( # noqa: C901 yaml_path = hass.config.path(YAML_DEVICES) devs_to_track: set[str] = set() devs_no_track: set[str] = set() + devs_advertise_time: dict[str, float] = {} devs_track_battery = {} interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) # if track new devices is true discover new devices @@ -178,6 +179,7 @@ async def async_setup_scanner( # noqa: C901 """Update from a ble callback.""" mac = service_info.address if mac in devs_to_track: + devs_advertise_time[mac] = service_info.time now = dt_util.utcnow() hass.async_create_task(async_see_device(mac, service_info.name)) if ( @@ -205,7 +207,9 @@ async def async_setup_scanner( # noqa: C901 # there have been no callbacks because the RSSI or # other properties have not changed. for service_info in bluetooth.async_discovered_service_info(hass, False): - _async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT) + # Only call _async_update_ble if the advertisement time has changed + if service_info.time != devs_advertise_time.get(service_info.address): + _async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT) cancels = [ bluetooth.async_register_callback( diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index a47f2bed591..e91943034df 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py new file mode 100644 index 00000000000..e8e8dd5ca40 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -0,0 +1,139 @@ +"""Select platform for BMW.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.charging_profile import ChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + current_option: Callable[[MyBMWVehicle], str] + remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): + """Describes BMW sensor entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { + # --- Generic --- + "target_soc": BMWSelectEntityDescription( + key="target_soc", + name="Target SoC", + is_available=lambda v: v.is_remote_set_target_soc_enabled, + options=[str(i * 5 + 20) for i in range(17)], + current_option=lambda v: str(v.fuel_and_battery.charging_target), + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + unit_of_measurement=PERCENTAGE, + ), + "ac_limit": BMWSelectEntityDescription( + key="ac_limit", + name="AC Charging Limit", + is_available=lambda v: v.is_remote_set_ac_limit_enabled, + dynamic_options=lambda v: [ + str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + ], + current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + ac_limit=int(o) + ), + icon="mdi:current-ac", + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "charging_mode": BMWSelectEntityDescription( + key="charging_mode", + name="Charging Mode", + is_available=lambda v: v.is_charging_plan_supported, + options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( + charging_mode=ChargingMode(o) + ), + icon="mdi:vector-point-select", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW lock from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSelect] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSelect(coordinator, vehicle, description) + for description in SELECT_TYPES.values() + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSelect(BMWBaseEntity, SelectEntity): + """Representation of BMW select entity.""" + + entity_description: BMWSelectEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSelectEntityDescription, + ) -> None: + """Initialize an BMW select.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + if description.dynamic_options: + self._attr_options = description.dynamic_options(vehicle) + self._attr_current_option = description.current_option(vehicle) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name + ) + self._attr_current_option = self.entity_description.current_option(self.vehicle) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + option, + ) + await self.entity_description.remote_service(self.vehicle, option) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index d00646d6ff4..36af3974482 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -17,9 +17,9 @@ from homeassistant.const import ( ATTR_SW_VERSION, ATTR_VIA_DEVICE, ) -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later from .const import DOMAIN from .utils import BondDevice, BondHub @@ -27,6 +27,7 @@ from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) _FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) +_BPUP_ALIVE_SCAN_INTERVAL = timedelta(seconds=60) class BondEntity(Entity): @@ -65,6 +66,7 @@ class BondEntity(Entity): self._attr_name = device.name self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state self._apply_state() + self._bpup_polling_fallback: CALLBACK_TYPE | None = None @property def device_info(self) -> DeviceInfo: @@ -100,12 +102,13 @@ class BondEntity(Entity): return device_info async def async_update(self) -> None: - """Fetch assumed state of the cover from the hub using API.""" + """Perform a manual update from API.""" await self._async_update_from_api() @callback def _async_update_if_bpup_not_alive(self, now: datetime) -> None: """Fetch via the API if BPUP is not alive.""" + self._async_schedule_bpup_alive_or_poll() if ( self.hass.is_stopping or self._bpup_subs.alive @@ -172,16 +175,22 @@ class BondEntity(Entity): """Subscribe to BPUP and start polling.""" await super().async_added_to_hass() self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) - self.async_on_remove( - async_track_time_interval( - self.hass, - self._async_update_if_bpup_not_alive, - _FALLBACK_SCAN_INTERVAL, - f"Bond {self.entity_id} fallback polling", - ) + self._async_schedule_bpup_alive_or_poll() + + @callback + def _async_schedule_bpup_alive_or_poll(self) -> None: + """Schedule the BPUP alive or poll.""" + alive = self._bpup_subs.alive + self._bpup_polling_fallback = async_call_later( + self.hass, + _BPUP_ALIVE_SCAN_INTERVAL if alive else _FALLBACK_SCAN_INTERVAL, + self._async_update_if_bpup_not_alive, ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from BPUP data on remove.""" await super().async_will_remove_from_hass() self._bpup_subs.unsubscribe(self._device_id, self._async_bpup_callback) + if self._bpup_polling_fallback: + self._bpup_polling_fallback() + self._bpup_polling_fallback = None diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 768ded6b64f..b382d97a2ae 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -36,14 +36,14 @@ class BraviaTVButtonDescription( BUTTONS: tuple[BraviaTVButtonDescription, ...] = ( BraviaTVButtonDescription( key="reboot", - name="Reboot", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.async_reboot_device(), ), BraviaTVButtonDescription( key="terminate_apps", - name="Terminate apps", + translation_key="terminate_apps", entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.async_terminate_apps(), ), diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index d66f44acc6c..aacaf81465b 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -44,5 +44,15 @@ "not_bravia_device": "The device is not a Bravia TV.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "button": { + "restart": { + "name": "[%key:component::button::entity_component::restart::name%]" + }, + "terminate_apps": { + "name": "Terminate apps" + } + } } } diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 33f1badcc1b..50c58d41667 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -25,61 +25,61 @@ from .entity import BroadlinkEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="air_quality", - name="Air quality", + translation_key="air_quality", ), SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="light", - name="Light", + translation_key="light", ), SensorEntityDescription( key="noise", - name="Noise", + translation_key="noise", ), SensorEntityDescription( key="power", - name="Current power", + translation_key="power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volt", - name="Voltage", + translation_key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current", - name="Current", + translation_key="current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="overload", - name="Overload", + translation_key="overload", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="totalconsum", - name="Total consumption", + translation_key="total_consumption", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index d324b0272a8..adff2303c74 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -43,5 +43,39 @@ "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "air_quality": { + "name": "[%key:component::sensor::entity_component::aqi::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "light": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "noise": { + "name": "Noise" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "overload": { + "name": "Overload" + }, + "total_consumption": { + "name": "Total consumption" + } + } } } diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 274576f0f31..191bfff249c 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -53,14 +53,14 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="status", icon="mdi:printer", - name="Status", + translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", icon="mdi:file-document-outline", - name="Page counter", + translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="bw_counter", icon="mdi:file-document-outline", - name="B/W counter", + translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -78,7 +78,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="color_counter", icon="mdi:file-document-outline", - name="Color counter", + translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -87,7 +87,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="duplex_unit_pages_counter", icon="mdi:file-document-outline", - name="Duplex unit pages counter", + translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_life", icon="mdi:chart-donut", - name="Drum remaining life", + translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -105,7 +105,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_pages", icon="mdi:chart-donut", - name="Drum remaining pages", + translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_counter", icon="mdi:chart-donut", - name="Drum counter", + translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -123,7 +123,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_life", icon="mdi:chart-donut", - name="Black drum remaining life", + translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_pages", icon="mdi:chart-donut", - name="Black drum remaining pages", + translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -141,7 +141,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_counter", icon="mdi:chart-donut", - name="Black drum counter", + translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -150,7 +150,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_life", icon="mdi:chart-donut", - name="Cyan drum remaining life", + translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", icon="mdi:chart-donut", - name="Cyan drum remaining pages", + translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -168,7 +168,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_counter", icon="mdi:chart-donut", - name="Cyan drum counter", + translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_life", icon="mdi:chart-donut", - name="Magenta drum remaining life", + translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -186,7 +186,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", icon="mdi:chart-donut", - name="Magenta drum remaining pages", + translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -195,7 +195,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_counter", icon="mdi:chart-donut", - name="Magenta drum counter", + translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_life", icon="mdi:chart-donut", - name="Yellow drum remaining life", + translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", icon="mdi:chart-donut", - name="Yellow drum remaining pages", + translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_counter", icon="mdi:chart-donut", - name="Yellow drum counter", + translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -231,7 +231,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="belt_unit_remaining_life", icon="mdi:current-ac", - name="Belt unit remaining life", + translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -240,7 +240,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="fuser_remaining_life", icon="mdi:water-outline", - name="Fuser remaining life", + translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,7 +249,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="laser_remaining_life", icon="mdi:spotlight-beam", - name="Laser remaining life", + translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -258,7 +258,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", icon="mdi:printer-3d", - name="PF Kit 1 remaining life", + translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -267,7 +267,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", icon="mdi:printer-3d", - name="PF Kit MP remaining life", + translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -276,7 +276,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Black toner remaining", + translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -285,7 +285,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan toner remaining", + translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta toner remaining", + translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow toner remaining", + translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -312,7 +312,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Black ink remaining", + translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -321,7 +321,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan ink remaining", + translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -330,7 +330,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta ink remaining", + translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -339,7 +339,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow ink remaining", + translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -347,7 +347,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="last_restart", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 9d7d42abefa..3ee3fe7609f 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -25,5 +25,111 @@ "unsupported_model": "This printer model is not supported.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "page_counter": { + "name": "Page counter" + }, + "bw_pages": { + "name": "B/W pages" + }, + "color_pages": { + "name": "Color pages" + }, + "duplex_unit_page_counter": { + "name": "Duplex unit page counter" + }, + "drum_remaining_life": { + "name": "Drum remaining life" + }, + "drum_remaining_pages": { + "name": "Drum remaining pages" + }, + "drum_page_counter": { + "name": "Drum page counter" + }, + "black_drum_remaining_life": { + "name": "Black drum remaining life" + }, + "black_drum_remaining_pages": { + "name": "Black drum remaining pages" + }, + "black_drum_page_counter": { + "name": "Black drum page counter" + }, + "cyan_drum_remaining_life": { + "name": "Cyan drum remaining life" + }, + "cyan_drum_remaining_pages": { + "name": "Cyan drum remaining pages" + }, + "cyan_drum_page_counter": { + "name": "Cyan drum page counter" + }, + "magenta_drum_remaining_life": { + "name": "Magenta drum remaining life" + }, + "magenta_drum_remaining_pages": { + "name": "Magenta drum remaining pages" + }, + "magenta_drum_page_counter": { + "name": "Magenta drum page counter" + }, + "yellow_drum_remaining_life": { + "name": "Yellow drum remaining life" + }, + "yellow_drum_remaining_pages": { + "name": "Yellow drum remaining pages" + }, + "yellow_drum_page_counter": { + "name": "Yellow drum page counter" + }, + "belt_unit_remaining_life": { + "name": "Belt unit remaining life" + }, + "fuser_remaining_life": { + "name": "Fuser remaining life" + }, + "laser_remaining_life": { + "name": "Laser remaining life" + }, + "pf_kit_1_remaining_life": { + "name": "PF Kit 1 remaining life" + }, + "pf_kit_mp_remaining_life": { + "name": "PF Kit MP remaining life" + }, + "black_toner_remaining": { + "name": "Black toner remaining" + }, + "cyan_toner_remaining": { + "name": "Cyan toner remaining" + }, + "magenta_toner_remaining": { + "name": "Magenta toner remaining" + }, + "yellow_toner_remaining": { + "name": "Yellow toner remaining" + }, + "black_ink_remaining": { + "name": "Black ink remaining" + }, + "cyan_ink_remaining": { + "name": "Cyan ink remaining" + }, + "magenta_ink_remaining": { + "name": "Magenta ink remaining" + }, + "yellow_ink_remaining": { + "name": "Yellow ink remaining" + }, + "last_restart": { + "name": "Last restart" + } + } } } diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 673009268c1..b1e4768a0a7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -380,7 +380,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_write_ha_state() unsub = async_track_time_interval( - hass, update_tokens, TOKEN_CHANGE_INTERVAL, "Camera update tokens" + hass, update_tokens, TOKEN_CHANGE_INTERVAL, name="Camera update tokens" ) @callback diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index be80ca340ff..48921303ce0 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["pychromecast==13.0.6"], + "requirements": ["pychromecast==13.0.7"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8352b566afe..0cb2665767b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -311,7 +311,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass=hass, delay=timedelta(hours=STARTUP_REPAIR_DELAY), - action=async_startup_repairs, + action=HassJob( + async_startup_repairs, "cloud startup repairs", cancel_on_shutdown=True + ), ) return True diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 377da7d60b7..44a42c78f09 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,6 +20,11 @@ from homeassistant.components.alexa import ( errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.homeassistant.exposed_entities import ( + async_get_assistant_settings, + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import entity_registry as er, start @@ -30,16 +35,17 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, + DOMAIN as CLOUD_DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" + # Time to wait when entity preferences have changed before syncing it to # the cloud. SYNC_DELAY = 1 @@ -64,7 +70,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -115,10 +121,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): """Return an identifier for the user that represents this config.""" return self._cloud_user + def _migrate_alexa_entity_settings_v1(self): + """Migrate alexa entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_ALEXA in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options) + async def async_initialize(self): """Initialize the Alexa config.""" await super().async_initialize() + if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + if self._prefs.alexa_settings_version < 2: + self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( + alexa_settings_version=ALEXA_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -126,19 +153,19 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): start.async_at_start(self.hass, hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, ) - def should_expose(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -160,6 +187,15 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) + @callback def async_invalidate_access_token(self): """Invalidate access token.""" @@ -233,32 +269,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): if not any( key in updated_prefs for key in ( - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, ) ): return - # If we update just entity preferences, delay updating - # as we might update more - if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: - if self._alexa_sync_unsub: - self._alexa_sync_unsub() - - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) - return - await self.async_sync_entities() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + # Delay updating as we might update more + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) + async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs - new_prefs = self._prefs.alexa_entity_configs + new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA) seen = set() to_update = [] diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9d5ed2ca28e..49b4b905ed3 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -19,6 +19,8 @@ PREF_USERNAME = "username" PREF_REMOTE_DOMAIN = "remote_domain" PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" +PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" +PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index cf5a1de73af..c47b05c264c 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -9,6 +9,10 @@ from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -22,14 +26,18 @@ from homeassistant.setup import async_setup_component from .const import ( CONF_ENTITY_CONFIG, + CONF_FILTER, DEFAULT_DISABLE_2FA, + DOMAIN as CLOUD_DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -48,8 +56,6 @@ class CloudGoogleConfig(AbstractConfig): self._user = cloud_user self._prefs = prefs self._cloud = cloud - self._cur_entity_prefs = self._prefs.google_entity_configs - self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() @property @@ -89,10 +95,35 @@ class CloudGoogleConfig(AbstractConfig): """Return Cloud User account.""" return self._user + def _migrate_google_entity_settings_v1(self): + """Migrate Google entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_GOOGLE in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + options[PREF_DISABLE_2FA] = _2fa_disabled + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, options + ) + async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2: + self._migrate_google_entity_settings_v1() + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) @@ -109,7 +140,9 @@ class CloudGoogleConfig(AbstractConfig): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, @@ -123,14 +156,11 @@ class CloudGoogleConfig(AbstractConfig): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_entity_id(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config["filter"].empty_filter: - return self._config["filter"](entity_id) - entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -154,6 +184,15 @@ class CloudGoogleConfig(AbstractConfig): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def _should_expose_entity_id(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) + @property def agent_user_id(self): """Return Agent User Id to use for query responses.""" @@ -168,11 +207,23 @@ class CloudGoogleConfig(AbstractConfig): """Get agent user ID making request.""" return self.agent_user_id - def should_2fa(self, state): + def _2fa_disabled_legacy(self, entity_id): """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) - return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_DISABLE_2FA) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_registry = er.async_get(self.hass) + + registry_entry = entity_registry.async_get(state.entity_id) + if not registry_entry: + # Handle the entity has been removed + return False + + assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {}) + return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) async def async_report_state(self, message, agent_user_id: str): """Send a state report to Google.""" @@ -218,14 +269,6 @@ class CloudGoogleConfig(AbstractConfig): # So when we change it, we need to sync all entities. sync_entities = True - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. - elif ( - self._cur_entity_prefs is not prefs.google_entity_configs - or self._cur_default_expose is not prefs.google_default_expose - ) and self._config["filter"].empty_filter: - self.async_schedule_google_sync_all() - if self.enabled and not self.is_local_sdk_active: self.async_enable_local_sdk() sync_entities = True @@ -233,12 +276,14 @@ class CloudGoogleConfig(AbstractConfig): self.async_disable_local_sdk() sync_entities = True - self._cur_entity_prefs = prefs.google_entity_configs - self._cur_default_expose = prefs.google_default_expose - if sync_entities and self.hass.is_running: await self.async_sync_entities_all() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self.async_schedule_google_sync_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6c4115ae28a..c25de5463b5 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,5 +1,6 @@ """The HTTP api to control the cloud integration.""" import asyncio +from collections.abc import Mapping import dataclasses from functools import wraps from http import HTTPStatus @@ -22,22 +23,24 @@ from homeassistant.components.alexa import ( from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .const import ( DOMAIN, - PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, + PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) +from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -66,11 +69,11 @@ async def async_setup(hass): websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) + websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_update) websocket_api.async_register_command(hass, alexa_list) - websocket_api.async_register_command(hass, alexa_update) websocket_api.async_register_command(hass, alexa_sync) websocket_api.async_register_command(hass, thingtalk_convert) @@ -350,8 +353,6 @@ async def websocket_subscription( vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, - vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str], - vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str], vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) @@ -523,6 +524,54 @@ async def websocket_remote_disconnect( connection.send_result(msg["id"], await _account_data(hass, cloud)) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/google_assistant/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def google_assistant_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single google assistant entity.""" + cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] + state = hass.states.get(entity_id) + + if not entity_registry.async_is_registered(entity_id) or not state: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"{entity_id} unknown or not in the entity registry", + ) + return + + entity = google_helpers.GoogleEntity(hass, gconf, state) + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Google assistant", + ) + return + + result = { + "entity_id": entity.entity_id, + "traits": [trait.name for trait in entity.traits()], + "might_2fa": entity.might_2fa_traits(), + } + + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @@ -536,11 +585,14 @@ async def google_assistant_list( """List all google assistant entities.""" cloud = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) entities = google_helpers.async_get_entities(hass, gconf) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -558,8 +610,7 @@ async def google_assistant_list( { "type": "cloud/google_assistant/entities/update", "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("disable_2fa"): bool, + vol.Optional(PREF_DISABLE_2FA): bool, } ) @websocket_api.async_response @@ -569,17 +620,30 @@ async def google_assistant_update( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Update google assistant config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") + """Update google assistant entity config.""" + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] - await cloud.client.prefs.async_update_google_entity_config(**changes) + if not (registry_entry := entity_registry.async_get(entity_id)): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_ALLOWED, + f"can't configure {entity_id}", + ) + return - connection.send_result( - msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + disable_2fa = msg[PREF_DISABLE_2FA] + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {}) + ) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + return + + assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa} + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, assistant_options ) + connection.send_result(msg["id"]) @websocket_api.require_admin @@ -595,11 +659,14 @@ async def alexa_list( """List all alexa entities.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() + entity_registry = er.async_get(hass) entities = alexa_entities.async_get_entities(hass, alexa_config) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -611,35 +678,6 @@ async def alexa_list( connection.send_result(msg["id"], result) -@websocket_api.require_admin -@_require_cloud_login -@websocket_api.websocket_command( - { - "type": "cloud/alexa/entities/update", - "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - } -) -@websocket_api.async_response -@_ws_handle_cloud_errors -async def alexa_update( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Update alexa entity config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") - - await cloud.client.prefs.async_update_alexa_entity_config(**changes) - - connection.send_result( - msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) - ) - - @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 2bff4003669..daf65865fc0 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Cloud", "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], - "dependencies": ["http", "webhook"], + "dependencies": ["homeassistant", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 7f27e7cf39b..75e1856503c 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,8 @@ """Preference management for cloud.""" from __future__ import annotations +from typing import Any + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook @@ -18,9 +20,9 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, + PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, - PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, @@ -29,14 +31,33 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_GOOGLE_SETTINGS_VERSION, PREF_REMOTE_DOMAIN, - PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, ) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 + +ALEXA_SETTINGS_VERSION = 2 +GOOGLE_SETTINGS_VERSION = 2 + + +class CloudPreferencesStore(Store): + """Store entity registry data.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + if old_minor_version < 2: + old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) + old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + + return old_data class CloudPreferences: @@ -45,7 +66,9 @@ class CloudPreferences: def __init__(self, hass): """Initialize cloud prefs.""" self._hass = hass - self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = CloudPreferencesStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) self._prefs = None self._listeners = [] self.last_updated: set[str] = set() @@ -79,14 +102,12 @@ class CloudPreferences: google_secure_devices_pin=UNDEFINED, cloudhooks=UNDEFINED, cloud_user=UNDEFINED, - google_entity_configs=UNDEFINED, - alexa_entity_configs=UNDEFINED, alexa_report_state=UNDEFINED, google_report_state=UNDEFINED, - alexa_default_expose=UNDEFINED, - google_default_expose=UNDEFINED, tts_default_voice=UNDEFINED, remote_domain=UNDEFINED, + alexa_settings_version=UNDEFINED, + google_settings_version=UNDEFINED, ): """Update user preferences.""" prefs = {**self._prefs} @@ -98,12 +119,10 @@ class CloudPreferences: (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), - (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), - (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), (PREF_ALEXA_REPORT_STATE, alexa_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state), - (PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose), - (PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose), + (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), + (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), ): @@ -112,53 +131,6 @@ class CloudPreferences: await self._save_prefs(prefs) - async def async_update_google_entity_config( - self, - *, - entity_id, - disable_2fa=UNDEFINED, - should_expose=UNDEFINED, - ): - """Update config for a Google entity.""" - entities = self.google_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ( - (PREF_DISABLE_2FA, disable_2fa), - (PREF_SHOULD_EXPOSE, should_expose), - ): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(google_entity_configs=updated_entities) - - async def async_update_alexa_entity_config( - self, *, entity_id, should_expose=UNDEFINED - ): - """Update config for an Alexa entity.""" - entities = self.alexa_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ((PREF_SHOULD_EXPOSE, should_expose),): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(alexa_entity_configs=updated_entities) - async def async_set_username(self, username) -> bool: """Set the username that is logged in.""" # Logging out. @@ -186,14 +158,12 @@ class CloudPreferences: """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, - PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_ENABLE_ALEXA: self.alexa_enabled, PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_REMOTE: self.remote_enabled, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, - PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, @@ -235,6 +205,11 @@ class CloudPreferences: """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property + def alexa_settings_version(self): + """Return version of Alexa settings.""" + return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + @property def google_enabled(self): """Return if Google is enabled.""" @@ -255,6 +230,11 @@ class CloudPreferences: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def google_settings_version(self): + """Return version of Google settings.""" + return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + @property def google_local_webhook_id(self): """Return Google webhook ID to receive local messages.""" @@ -319,6 +299,7 @@ class CloudPreferences: return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER: None, PREF_CLOUDHOOKS: {}, PREF_ENABLE_ALEXA: True, @@ -326,6 +307,7 @@ class CloudPreferences: PREF_ENABLE_REMOTE: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index bbf4ef287d6..a10b0c98cd8 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -4,11 +4,16 @@ from hass_nabucasa import Cloud from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError import voluptuous as vol -from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import ( + ATTR_AUDIO_OUTPUT, + CONF_LANG, + PLATFORM_SCHEMA, + Provider, +) from .const import DOMAIN -CONF_GENDER = "gender" +ATTR_GENDER = "gender" SUPPORT_LANGUAGES = list({key[0] for key in MAP_VOICE}) @@ -18,8 +23,8 @@ def validate_lang(value): if (lang := value.get(CONF_LANG)) is None: return value - if (gender := value.get(CONF_GENDER)) is None: - gender = value[CONF_GENDER] = next( + if (gender := value.get(ATTR_GENDER)) is None: + gender = value[ATTR_GENDER] = next( (chk_gender for chk_lang, chk_gender in MAP_VOICE if chk_lang == lang), None ) @@ -33,7 +38,7 @@ PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG): str, - vol.Optional(CONF_GENDER): str, + vol.Optional(ATTR_GENDER): str, } ), validate_lang, @@ -49,7 +54,7 @@ async def async_get_engine(hass, config, discovery_info=None): gender = None else: language = config[CONF_LANG] - gender = config[CONF_GENDER] + gender = config[ATTR_GENDER] return CloudProvider(cloud, language, gender) @@ -87,12 +92,15 @@ class CloudProvider(Provider): @property def supported_options(self): """Return list of supported options like voice, emotion.""" - return [CONF_GENDER] + return [ATTR_GENDER, ATTR_AUDIO_OUTPUT] @property def default_options(self): """Return a dict include default options.""" - return {CONF_GENDER: self._gender} + return { + ATTR_GENDER: self._gender, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + } async def async_get_tts_audio(self, message, language, options=None): """Load TTS from NabuCasa Cloud.""" @@ -101,10 +109,10 @@ class CloudProvider(Provider): data = await self.cloud.voice.process_tts( message, language, - gender=options[CONF_GENDER], - output=AudioOutput.MP3, + gender=options[ATTR_GENDER], + output=options[ATTR_AUDIO_OUTPUT], ) except VoiceError: return (None, None) - return ("mp3", data) + return (str(options[ATTR_AUDIO_OUTPUT]), data) diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 680220371b4..9f133c0b0ca 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -35,13 +35,13 @@ class CO2SensorEntityDescription(SensorEntityDescription): SENSORS = ( CO2SensorEntityDescription( key="carbonIntensity", - name="CO2 intensity", + translation_key="carbon_intensity", unique_id="co2intensity", # No unit, it's extracted from response. ), CO2SensorEntityDescription( key="fossilFuelPercentage", - name="Grid fossil fuel percentage", + translation_key="fossil_fuel_percentage", native_unit_of_measurement=PERCENTAGE, ), ) diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 2fe5b79c907..05ea76f3179 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -30,5 +30,11 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "api_ratelimit": "API Ratelimit exceeded" } + }, + "entity": { + "sensor": { + "carbon_intensity": { "name": "CO2 intensity" }, + "fossil_fuel_percentage": { "name": "Grid fossil fuel percentage" } + } } } diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index f459e415661..b6a2b8d83fa 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -137,8 +137,11 @@ class CommandSensor(SensorEntity): _LOGGER.warning("Unable to parse output as JSON: %s", value) else: _LOGGER.warning("Empty reply found when expecting JSON data") + if self._value_template is None: + self._attr_native_value = None + return - elif self._value_template is not None: + if self._value_template is not None: self._attr_native_value = ( self._value_template.async_render_with_possible_json_value( value, diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index e2e00a2652a..5009530dc31 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging import re -from typing import Any +from typing import Any, TypedDict import voluptuous as vol @@ -20,6 +20,15 @@ from homeassistant.loader import bind_hass from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .default_agent import DefaultAgent +__all__ = [ + "DOMAIN", + "async_converse", + "async_get_agent_info", + "async_set_agent", + "async_unset_agent", + "async_setup", +] + _LOGGER = logging.getLogger(__name__) ATTR_TEXT = "text" @@ -270,6 +279,31 @@ class ConversationProcessView(http.HomeAssistantView): return self.json(result.as_dict()) +class AgentInfo(TypedDict): + """Dictionary holding agent info.""" + + id: str + name: str + + +@core.callback +def async_get_agent_info( + hass: core.HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = _get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info["id"] == agent_id: + return agent_info + + return None + + async def async_converse( hass: core.HomeAssistant, text: str, @@ -332,12 +366,15 @@ class AgentManager: return self._builtin_agent + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + return self._agents[agent_id] @core.callback - def async_get_agent_info(self) -> list[dict[str, Any]]: + def async_get_agent_info(self) -> list[AgentInfo]: """List all agents.""" - agents = [ + agents: list[AgentInfo] = [ { "id": AgentManager.HOME_ASSISTANT_AGENT, "name": "Home Assistant", diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py deleted file mode 100644 index a3bc07ee0a1..00000000000 --- a/homeassistant/components/coronavirus/__init__.py +++ /dev/null @@ -1,88 +0,0 @@ -"""The Coronavirus integration.""" -from datetime import timedelta -import logging - -import async_timeout -import coronavirus - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - aiohttp_client, - entity_registry as er, - update_coordinator, -) -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR] - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Coronavirus component.""" - # Make sure coordinator is initialized. - await get_coordinator(hass) - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Coronavirus from a config entry.""" - if isinstance(entry.data["country"], int): - hass.config_entries.async_update_entry( - entry, data={**entry.data, "country": entry.title} - ) - - @callback - def _async_migrator(entity_entry: er.RegistryEntry): - """Migrate away from unstable ID.""" - country, info_type = entity_entry.unique_id.rsplit("-", 1) - if not country.isnumeric(): - return None - return {"new_unique_id": f"{entry.title}-{info_type}"} - - await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) - - if not entry.unique_id: - hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) - - coordinator = await get_coordinator(hass) - if not coordinator.last_update_success: - await coordinator.async_config_entry_first_refresh() - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def get_coordinator( - hass: HomeAssistant, -) -> update_coordinator.DataUpdateCoordinator: - """Get the data update coordinator.""" - if DOMAIN in hass.data: - return hass.data[DOMAIN] - - async def async_get_cases(): - async with async_timeout.timeout(10): - return { - case.country: case - for case in await coronavirus.get_cases( - aiohttp_client.async_get_clientsession(hass) - ) - } - - hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( - hass, - logging.getLogger(__name__), - name=DOMAIN, - update_method=async_get_cases, - update_interval=timedelta(hours=1), - ) - await hass.data[DOMAIN].async_refresh() - return hass.data[DOMAIN] diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py deleted file mode 100644 index 81e4f06f57f..00000000000 --- a/homeassistant/components/coronavirus/config_flow.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Config flow for Coronavirus integration.""" -from __future__ import annotations - -from typing import Any - -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult - -from . import get_coordinator -from .const import DOMAIN, OPTION_WORLDWIDE - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Coronavirus.""" - - VERSION = 1 - - _options: dict[str, Any] | None = None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - - if self._options is None: - coordinator = await get_coordinator(self.hass) - if not coordinator.last_update_success or coordinator.data is None: - return self.async_abort(reason="cannot_connect") - - self._options = {OPTION_WORLDWIDE: "Worldwide"} - for case in sorted( - coordinator.data.values(), key=lambda case: case.country - ): - self._options[case.country] = case.country - - if user_input is not None: - await self.async_set_unique_id(user_input["country"]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=self._options[user_input["country"]], data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required("country"): vol.In(self._options)}), - errors=errors, - ) diff --git a/homeassistant/components/coronavirus/const.py b/homeassistant/components/coronavirus/const.py deleted file mode 100644 index e1ffa64e88c..00000000000 --- a/homeassistant/components/coronavirus/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for the Coronavirus integration.""" -from coronavirus import DEFAULT_SOURCE - -DOMAIN = "coronavirus" -OPTION_WORLDWIDE = "__worldwide" -ATTRIBUTION = f"Data provided by {DEFAULT_SOURCE.NAME}" diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json deleted file mode 100644 index a053b4056c0..00000000000 --- a/homeassistant/components/coronavirus/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "coronavirus", - "name": "Coronavirus (COVID-19)", - "codeowners": ["@home-assistant/core"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/coronavirus", - "iot_class": "cloud_polling", - "loggers": ["coronavirus"], - "requirements": ["coronavirus==1.1.1"] -} diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py deleted file mode 100644 index 7fa7c5aed08..00000000000 --- a/homeassistant/components/coronavirus/sensor.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Sensor platform for the Corona virus.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import get_coordinator -from .const import ATTRIBUTION, OPTION_WORLDWIDE - -SENSORS = { - "confirmed": "mdi:emoticon-neutral-outline", - "current": "mdi:emoticon-sad-outline", - "recovered": "mdi:emoticon-happy-outline", - "deaths": "mdi:emoticon-cry-outline", -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Defer sensor setup to the shared sensor module.""" - coordinator = await get_coordinator(hass) - - async_add_entities( - CoronavirusSensor(coordinator, config_entry.data["country"], info_type) - for info_type in SENSORS - ) - - -class CoronavirusSensor(CoordinatorEntity, SensorEntity): - """Sensor representing corona virus data.""" - - _attr_attribution = ATTRIBUTION - _attr_native_unit_of_measurement = "people" - - def __init__(self, coordinator, country, info_type): - """Initialize coronavirus sensor.""" - super().__init__(coordinator) - self._attr_icon = SENSORS[info_type] - self._attr_unique_id = f"{country}-{info_type}" - if country == OPTION_WORLDWIDE: - self._attr_name = f"Worldwide Coronavirus {info_type}" - else: - self._attr_name = ( - f"{coordinator.data[country].country} Coronavirus {info_type}" - ) - - self.country = country - self.info_type = info_type - - @property - def available(self) -> bool: - """Return if sensor is available.""" - return self.coordinator.last_update_success and ( - self.country in self.coordinator.data or self.country == OPTION_WORLDWIDE - ) - - @property - def native_value(self): - """State of the sensor.""" - if self.country == OPTION_WORLDWIDE: - sum_cases = 0 - for case in self.coordinator.data.values(): - if (value := getattr(case, self.info_type)) is None: - continue - sum_cases += value - - return sum_cases - - return getattr(self.coordinator.data[self.country], self.info_type) diff --git a/homeassistant/components/coronavirus/strings.json b/homeassistant/components/coronavirus/strings.json deleted file mode 100644 index e0b29d6c8db..00000000000 --- a/homeassistant/components/coronavirus/strings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Pick a country to monitor", - "data": { "country": "Country" } - } - }, - "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - } - } -} diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 30238073b16..db739f3f0db 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -106,7 +106,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = CounterStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -140,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class CounterStorageCollection(collection.StorageCollection): +class CounterStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -154,10 +153,10 @@ class CounterStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class Counter(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 9905228c26a..b4a33392894 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -20,7 +20,6 @@ _RESOURCE = "http://apilayer.net/api/live" DEFAULT_BASE = "USD" DEFAULT_NAME = "CurrencyLayer Sensor" -ICON = "mdi:currency" SCAN_INTERVAL = timedelta(hours=4) @@ -60,6 +59,7 @@ class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" _attr_attribution = "Data provided by currencylayer.com" + _attr_icon = "mdi:currency" def __init__(self, rest, base, quote): """Initialize the sensor.""" @@ -78,11 +78,6 @@ class CurrencylayerSensor(SensorEntity): """Return the name of the sensor.""" return self._base - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/darksky/__init__.py b/homeassistant/components/darksky/__init__.py deleted file mode 100644 index 90a5d06dc0e..00000000000 --- a/homeassistant/components/darksky/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The darksky component.""" diff --git a/homeassistant/components/darksky/manifest.json b/homeassistant/components/darksky/manifest.json deleted file mode 100644 index 6ff20b11f2a..00000000000 --- a/homeassistant/components/darksky/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "darksky", - "name": "Dark Sky", - "codeowners": ["@fabaff"], - "documentation": "https://www.home-assistant.io/integrations/darksky", - "iot_class": "cloud_polling", - "loggers": ["forecastio"], - "requirements": ["python-forecastio==1.4.0"] -} diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py deleted file mode 100644 index ef1ef0466d4..00000000000 --- a/homeassistant/components/darksky/sensor.py +++ /dev/null @@ -1,927 +0,0 @@ -"""Support for Dark Sky weather service.""" -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import timedelta -import logging -from typing import Literal, NamedTuple - -import forecastio -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_SCAN_INTERVAL, - DEGREE, - PERCENTAGE, - UV_INDEX, - UnitOfLength, - UnitOfPrecipitationDepth, - UnitOfPressure, - UnitOfSpeed, - UnitOfTemperature, - UnitOfVolumetricFlux, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -from homeassistant.util.unit_system import METRIC_SYSTEM - -_LOGGER = logging.getLogger(__name__) - -CONF_FORECAST = "forecast" -CONF_HOURLY_FORECAST = "hourly_forecast" -CONF_LANGUAGE = "language" -CONF_UNITS = "units" - -DEFAULT_LANGUAGE = "en" -DEFAULT_NAME = "Dark Sky" -SCAN_INTERVAL = timedelta(seconds=300) - -DEPRECATED_SENSOR_TYPES = { - "apparent_temperature_max", - "apparent_temperature_min", - "temperature_max", - "temperature_min", -} - -MAP_UNIT_SYSTEM: dict[ - Literal["si", "us", "ca", "uk", "uk2"], - Literal["si_unit", "us_unit", "ca_unit", "uk_unit", "uk2_unit"], -] = { - "si": "si_unit", - "us": "us_unit", - "ca": "ca_unit", - "uk": "uk_unit", - "uk2": "uk2_unit", -} - - -@dataclass -class DarkskySensorEntityDescription(SensorEntityDescription): - """Describes Darksky sensor entity.""" - - si_unit: str | None = None - us_unit: str | None = None - ca_unit: str | None = None - uk_unit: str | None = None - uk2_unit: str | None = None - forecast_mode: list[str] = field(default_factory=list) - - -SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { - "summary": DarkskySensorEntityDescription( - key="summary", - name="Summary", - forecast_mode=["currently", "hourly", "daily"], - ), - "minutely_summary": DarkskySensorEntityDescription( - key="minutely_summary", - name="Minutely Summary", - forecast_mode=[], - ), - "hourly_summary": DarkskySensorEntityDescription( - key="hourly_summary", - name="Hourly Summary", - forecast_mode=[], - ), - "daily_summary": DarkskySensorEntityDescription( - key="daily_summary", - name="Daily Summary", - forecast_mode=[], - ), - "icon": DarkskySensorEntityDescription( - key="icon", - name="Icon", - forecast_mode=["currently", "hourly", "daily"], - ), - "nearest_storm_distance": DarkskySensorEntityDescription( - key="nearest_storm_distance", - name="Nearest Storm Distance", - si_unit=UnitOfLength.KILOMETERS, - us_unit=UnitOfLength.MILES, - ca_unit=UnitOfLength.KILOMETERS, - uk_unit=UnitOfLength.KILOMETERS, - uk2_unit=UnitOfLength.MILES, - icon="mdi:weather-lightning", - forecast_mode=["currently"], - ), - "nearest_storm_bearing": DarkskySensorEntityDescription( - key="nearest_storm_bearing", - name="Nearest Storm Bearing", - si_unit=DEGREE, - us_unit=DEGREE, - ca_unit=DEGREE, - uk_unit=DEGREE, - uk2_unit=DEGREE, - icon="mdi:weather-lightning", - forecast_mode=["currently"], - ), - "precip_type": DarkskySensorEntityDescription( - key="precip_type", - name="Precip", - icon="mdi:weather-pouring", - forecast_mode=["currently", "minutely", "hourly", "daily"], - ), - "precip_intensity": DarkskySensorEntityDescription( - key="precip_intensity", - name="Precip Intensity", - si_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - ca_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk2_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - icon="mdi:weather-rainy", - forecast_mode=["currently", "minutely", "hourly", "daily"], - ), - "precip_probability": DarkskySensorEntityDescription( - key="precip_probability", - name="Precip Probability", - si_unit=PERCENTAGE, - us_unit=PERCENTAGE, - ca_unit=PERCENTAGE, - uk_unit=PERCENTAGE, - uk2_unit=PERCENTAGE, - icon="mdi:water-percent", - forecast_mode=["currently", "minutely", "hourly", "daily"], - ), - "precip_accumulation": DarkskySensorEntityDescription( - key="precip_accumulation", - name="Precip Accumulation", - device_class=SensorDeviceClass.PRECIPITATION, - si_unit=UnitOfPrecipitationDepth.CENTIMETERS, - us_unit=UnitOfPrecipitationDepth.INCHES, - ca_unit=UnitOfPrecipitationDepth.CENTIMETERS, - uk_unit=UnitOfPrecipitationDepth.CENTIMETERS, - uk2_unit=UnitOfPrecipitationDepth.CENTIMETERS, - icon="mdi:weather-snowy", - forecast_mode=["hourly", "daily"], - ), - "temperature": DarkskySensorEntityDescription( - key="temperature", - name="Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["currently", "hourly"], - ), - "apparent_temperature": DarkskySensorEntityDescription( - key="apparent_temperature", - name="Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["currently", "hourly"], - ), - "dew_point": DarkskySensorEntityDescription( - key="dew_point", - name="Dew Point", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["currently", "hourly", "daily"], - ), - "wind_speed": DarkskySensorEntityDescription( - key="wind_speed", - name="Wind Speed", - device_class=SensorDeviceClass.WIND_SPEED, - si_unit=UnitOfSpeed.METERS_PER_SECOND, - us_unit=UnitOfSpeed.MILES_PER_HOUR, - ca_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - uk_unit=UnitOfSpeed.MILES_PER_HOUR, - uk2_unit=UnitOfSpeed.MILES_PER_HOUR, - forecast_mode=["currently", "hourly", "daily"], - ), - "wind_bearing": DarkskySensorEntityDescription( - key="wind_bearing", - name="Wind Bearing", - si_unit=DEGREE, - us_unit=DEGREE, - ca_unit=DEGREE, - uk_unit=DEGREE, - uk2_unit=DEGREE, - icon="mdi:compass", - forecast_mode=["currently", "hourly", "daily"], - ), - "wind_gust": DarkskySensorEntityDescription( - key="wind_gust", - name="Wind Gust", - device_class=SensorDeviceClass.WIND_SPEED, - si_unit=UnitOfSpeed.METERS_PER_SECOND, - us_unit=UnitOfSpeed.MILES_PER_HOUR, - ca_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - uk_unit=UnitOfSpeed.MILES_PER_HOUR, - uk2_unit=UnitOfSpeed.MILES_PER_HOUR, - icon="mdi:weather-windy-variant", - forecast_mode=["currently", "hourly", "daily"], - ), - "cloud_cover": DarkskySensorEntityDescription( - key="cloud_cover", - name="Cloud Coverage", - si_unit=PERCENTAGE, - us_unit=PERCENTAGE, - ca_unit=PERCENTAGE, - uk_unit=PERCENTAGE, - uk2_unit=PERCENTAGE, - icon="mdi:weather-partly-cloudy", - forecast_mode=["currently", "hourly", "daily"], - ), - "humidity": DarkskySensorEntityDescription( - key="humidity", - name="Humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - si_unit=PERCENTAGE, - us_unit=PERCENTAGE, - ca_unit=PERCENTAGE, - uk_unit=PERCENTAGE, - uk2_unit=PERCENTAGE, - forecast_mode=["currently", "hourly", "daily"], - ), - "pressure": DarkskySensorEntityDescription( - key="pressure", - name="Pressure", - device_class=SensorDeviceClass.PRESSURE, - si_unit=UnitOfPressure.MBAR, - us_unit=UnitOfPressure.MBAR, - ca_unit=UnitOfPressure.MBAR, - uk_unit=UnitOfPressure.MBAR, - uk2_unit=UnitOfPressure.MBAR, - forecast_mode=["currently", "hourly", "daily"], - ), - "visibility": DarkskySensorEntityDescription( - key="visibility", - name="Visibility", - si_unit=UnitOfLength.KILOMETERS, - us_unit=UnitOfLength.MILES, - ca_unit=UnitOfLength.KILOMETERS, - uk_unit=UnitOfLength.KILOMETERS, - uk2_unit=UnitOfLength.MILES, - icon="mdi:eye", - forecast_mode=["currently", "hourly", "daily"], - ), - "ozone": DarkskySensorEntityDescription( - key="ozone", - name="Ozone", - device_class=SensorDeviceClass.OZONE, - si_unit="DU", - us_unit="DU", - ca_unit="DU", - uk_unit="DU", - uk2_unit="DU", - forecast_mode=["currently", "hourly", "daily"], - ), - "apparent_temperature_max": DarkskySensorEntityDescription( - key="apparent_temperature_max", - name="Daily High Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "apparent_temperature_high": DarkskySensorEntityDescription( - key="apparent_temperature_high", - name="Daytime High Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "apparent_temperature_min": DarkskySensorEntityDescription( - key="apparent_temperature_min", - name="Daily Low Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "apparent_temperature_low": DarkskySensorEntityDescription( - key="apparent_temperature_low", - name="Overnight Low Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_max": DarkskySensorEntityDescription( - key="temperature_max", - name="Daily High Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_high": DarkskySensorEntityDescription( - key="temperature_high", - name="Daytime High Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_min": DarkskySensorEntityDescription( - key="temperature_min", - name="Daily Low Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_low": DarkskySensorEntityDescription( - key="temperature_low", - name="Overnight Low Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "precip_intensity_max": DarkskySensorEntityDescription( - key="precip_intensity_max", - name="Daily Max Precip Intensity", - si_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - ca_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk2_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - icon="mdi:thermometer", - forecast_mode=["daily"], - ), - "uv_index": DarkskySensorEntityDescription( - key="uv_index", - name="UV Index", - si_unit=UV_INDEX, - us_unit=UV_INDEX, - ca_unit=UV_INDEX, - uk_unit=UV_INDEX, - uk2_unit=UV_INDEX, - icon="mdi:weather-sunny", - forecast_mode=["currently", "hourly", "daily"], - ), - "moon_phase": DarkskySensorEntityDescription( - key="moon_phase", - name="Moon Phase", - icon="mdi:weather-night", - forecast_mode=["daily"], - ), - "sunrise_time": DarkskySensorEntityDescription( - key="sunrise_time", - name="Sunrise", - icon="mdi:white-balance-sunny", - forecast_mode=["daily"], - ), - "sunset_time": DarkskySensorEntityDescription( - key="sunset_time", - name="Sunset", - icon="mdi:weather-night", - forecast_mode=["daily"], - ), - "alerts": DarkskySensorEntityDescription( - key="alerts", - name="Alerts", - icon="mdi:alert-circle-outline", - forecast_mode=[], - ), -} - - -class ConditionPicture(NamedTuple): - """Entity picture and icon for condition.""" - - entity_picture: str - icon: str - - -CONDITION_PICTURES: dict[str, ConditionPicture] = { - "clear-day": ConditionPicture( - entity_picture="/static/images/darksky/weather-sunny.svg", - icon="mdi:weather-sunny", - ), - "clear-night": ConditionPicture( - entity_picture="/static/images/darksky/weather-night.svg", - icon="mdi:weather-night", - ), - "rain": ConditionPicture( - entity_picture="/static/images/darksky/weather-pouring.svg", - icon="mdi:weather-pouring", - ), - "snow": ConditionPicture( - entity_picture="/static/images/darksky/weather-snowy.svg", - icon="mdi:weather-snowy", - ), - "sleet": ConditionPicture( - entity_picture="/static/images/darksky/weather-hail.svg", - icon="mdi:weather-snowy-rainy", - ), - "wind": ConditionPicture( - entity_picture="/static/images/darksky/weather-windy.svg", - icon="mdi:weather-windy", - ), - "fog": ConditionPicture( - entity_picture="/static/images/darksky/weather-fog.svg", - icon="mdi:weather-fog", - ), - "cloudy": ConditionPicture( - entity_picture="/static/images/darksky/weather-cloudy.svg", - icon="mdi:weather-cloudy", - ), - "partly-cloudy-day": ConditionPicture( - entity_picture="/static/images/darksky/weather-partlycloudy.svg", - icon="mdi:weather-partly-cloudy", - ), - "partly-cloudy-night": ConditionPicture( - entity_picture="/static/images/darksky/weather-cloudy.svg", - icon="mdi:weather-night-partly-cloudy", - ), -} - -# Language Supported Codes -LANGUAGE_CODES = [ - "ar", - "az", - "be", - "bg", - "bn", - "bs", - "ca", - "cs", - "da", - "de", - "el", - "en", - "ja", - "ka", - "kn", - "ko", - "eo", - "es", - "et", - "fi", - "fr", - "he", - "hi", - "hr", - "hu", - "id", - "is", - "it", - "kw", - "lv", - "ml", - "mr", - "nb", - "nl", - "pa", - "pl", - "pt", - "ro", - "ru", - "sk", - "sl", - "sr", - "sv", - "ta", - "te", - "tet", - "tr", - "uk", - "ur", - "x-pig-latin", - "zh", - "zh-tw", -] - -ALLOWED_UNITS = ["auto", "si", "us", "ca", "uk", "uk2"] - -ALERTS_ATTRS = ["time", "description", "expires", "severity", "uri", "regions", "title"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNITS): vol.In(ALLOWED_UNITS), - vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), - vol.Inclusive( - CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.longitude, - vol.Optional(CONF_FORECAST): vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), - vol.Optional(CONF_HOURLY_FORECAST): vol.All( - cv.ensure_list, [vol.Range(min=0, max=48)] - ), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dark Sky sensor.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - language = config.get(CONF_LANGUAGE) - interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - - if CONF_UNITS in config: - units = config[CONF_UNITS] - elif hass.config.units is METRIC_SYSTEM: - units = "si" - else: - units = "us" - - forecast_data = DarkSkyData( - api_key=config.get(CONF_API_KEY), - latitude=latitude, - longitude=longitude, - units=units, - language=language, - interval=interval, - ) - forecast_data.update() - forecast_data.update_currently() - - # If connection failed don't setup platform. - if forecast_data.data is None: - return - - name = config.get(CONF_NAME) - - forecast = config.get(CONF_FORECAST) - forecast_hour = config.get(CONF_HOURLY_FORECAST) - sensors: list[SensorEntity] = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - if variable in DEPRECATED_SENSOR_TYPES: - _LOGGER.warning("Monitored condition %s is deprecated", variable) - description = SENSOR_TYPES[variable] - if not description.forecast_mode or "currently" in description.forecast_mode: - if variable == "alerts": - sensors.append(DarkSkyAlertSensor(forecast_data, description, name)) - else: - sensors.append(DarkSkySensor(forecast_data, description, name)) - - if forecast is not None and "daily" in description.forecast_mode: - sensors.extend( - [ - DarkSkySensor( - forecast_data, description, name, forecast_day=forecast_day - ) - for forecast_day in forecast - ] - ) - if forecast_hour is not None and "hourly" in description.forecast_mode: - sensors.extend( - [ - DarkSkySensor( - forecast_data, description, name, forecast_hour=forecast_h - ) - for forecast_h in forecast_hour - ] - ) - - add_entities(sensors, True) - - -class DarkSkySensor(SensorEntity): - """Implementation of a Dark Sky sensor.""" - - _attr_attribution = "Powered by Dark Sky" - entity_description: DarkskySensorEntityDescription - - def __init__( - self, - forecast_data, - description: DarkskySensorEntityDescription, - name, - forecast_day=None, - forecast_hour=None, - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.forecast_data = forecast_data - self.forecast_day = forecast_day - self.forecast_hour = forecast_hour - self._icon: str | None = None - - if forecast_day is not None: - self._attr_name = f"{name} {description.name} {forecast_day}d" - elif forecast_hour is not None: - self._attr_name = f"{name} {description.name} {forecast_hour}h" - else: - self._attr_name = f"{name} {description.name}" - - @property - def unit_system(self): - """Return the unit system of this entity.""" - return self.forecast_data.unit_system - - @property - def entity_picture(self) -> str | None: - """Return the entity picture to use in the frontend, if any.""" - if self._icon is None or "summary" not in self.entity_description.key: - return None - - if self._icon in CONDITION_PICTURES: - return CONDITION_PICTURES[self._icon].entity_picture - - return None - - def update_unit_of_measurement(self) -> None: - """Update units based on unit system.""" - unit_key = MAP_UNIT_SYSTEM.get(self.unit_system, "si_unit") - self._attr_native_unit_of_measurement = getattr( - self.entity_description, unit_key - ) - - @property - def icon(self) -> str | None: - """Icon to use in the frontend, if any.""" - if ( - "summary" in self.entity_description.key - and self._icon in CONDITION_PICTURES - ): - return CONDITION_PICTURES[self._icon].icon - - return self.entity_description.icon - - def update(self) -> None: - """Get the latest data from Dark Sky and updates the states.""" - # Call the API for new forecast data. Each sensor will re-trigger this - # same exact call, but that's fine. We cache results for a short period - # of time to prevent hitting API limits. Note that Dark Sky will - # charge users for too many calls in 1 day, so take care when updating. - self.forecast_data.update() - self.update_unit_of_measurement() - - sensor_type = self.entity_description.key - if sensor_type == "minutely_summary": - self.forecast_data.update_minutely() - minutely = self.forecast_data.data_minutely - self._attr_native_value = getattr(minutely, "summary", "") - self._icon = getattr(minutely, "icon", "") - elif sensor_type == "hourly_summary": - self.forecast_data.update_hourly() - hourly = self.forecast_data.data_hourly - self._attr_native_value = getattr(hourly, "summary", "") - self._icon = getattr(hourly, "icon", "") - elif self.forecast_hour is not None: - self.forecast_data.update_hourly() - hourly = self.forecast_data.data_hourly - if hasattr(hourly, "data"): - self._attr_native_value = self.get_state( - hourly.data[self.forecast_hour] - ) - else: - self._attr_native_value = 0 - elif sensor_type == "daily_summary": - self.forecast_data.update_daily() - daily = self.forecast_data.data_daily - self._attr_native_value = getattr(daily, "summary", "") - self._icon = getattr(daily, "icon", "") - elif self.forecast_day is not None: - self.forecast_data.update_daily() - daily = self.forecast_data.data_daily - if hasattr(daily, "data"): - self._attr_native_value = self.get_state(daily.data[self.forecast_day]) - else: - self._attr_native_value = 0 - else: - self.forecast_data.update_currently() - currently = self.forecast_data.data_currently - self._attr_native_value = self.get_state(currently) - - def get_state(self, data): - """Return a new state based on the type. - - If the sensor type is unknown, the current state is returned. - """ - sensor_type = self.entity_description.key - lookup_type = convert_to_camel(sensor_type) - - if (state := getattr(data, lookup_type, None)) is None: - return None - - if "summary" in sensor_type: - self._icon = getattr(data, "icon", "") - - # Some state data needs to be rounded to whole values or converted to - # percentages - if sensor_type in {"precip_probability", "cloud_cover", "humidity"}: - return round(state * 100, 1) - - if sensor_type in { - "dew_point", - "temperature", - "apparent_temperature", - "temperature_low", - "apparent_temperature_low", - "temperature_min", - "apparent_temperature_min", - "temperature_high", - "apparent_temperature_high", - "temperature_max", - "apparent_temperature_max", - "precip_accumulation", - "pressure", - "ozone", - "uvIndex", - }: - return round(state, 1) - return state - - -class DarkSkyAlertSensor(SensorEntity): - """Implementation of a Dark Sky sensor.""" - - entity_description: DarkskySensorEntityDescription - _attr_native_value: int | None - - def __init__( - self, forecast_data, description: DarkskySensorEntityDescription, name - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.forecast_data = forecast_data - self._alerts = None - - self._attr_name = f"{name} {description.name}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - if self._attr_native_value is not None and self._attr_native_value > 0: - return "mdi:alert-circle" - return "mdi:alert-circle-outline" - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._alerts - - def update(self) -> None: - """Get the latest data from Dark Sky and updates the states.""" - # Call the API for new forecast data. Each sensor will re-trigger this - # same exact call, but that's fine. We cache results for a short period - # of time to prevent hitting API limits. Note that Dark Sky will - # charge users for too many calls in 1 day, so take care when updating. - self.forecast_data.update() - self.forecast_data.update_alerts() - alerts = self.forecast_data.data_alerts - self._attr_native_value = self.get_state(alerts) - - def get_state(self, data): - """Return a new state based on the type. - - If the sensor type is unknown, the current state is returned. - """ - alerts = {} - if data is None: - self._alerts = alerts - return data - - multiple_alerts = len(data) > 1 - for i, alert in enumerate(data): - for attr in ALERTS_ATTRS: - if multiple_alerts: - dkey = f"{attr}_{i!s}" - else: - dkey = attr - alerts[dkey] = getattr(alert, attr) - self._alerts = alerts - - return len(data) - - -def convert_to_camel(data): - """Convert snake case (foo_bar_bat) to camel case (fooBarBat). - - This is not pythonic, but needed for certain situations. - """ - components = data.split("_") - capital_components = "".join(x.title() for x in components[1:]) - return f"{components[0]}{capital_components}" - - -class DarkSkyData: - """Get the latest data from Darksky.""" - - def __init__(self, api_key, latitude, longitude, units, language, interval): - """Initialize the data object.""" - self._api_key = api_key - self.latitude = latitude - self.longitude = longitude - self.units = units - self.language = language - self._connect_error = False - - self.data = None - self.unit_system = None - self.data_currently = None - self.data_minutely = None - self.data_hourly = None - self.data_daily = None - self.data_alerts = None - - # Apply throttling to methods using configured interval - self.update = Throttle(interval)(self._update) - self.update_currently = Throttle(interval)(self._update_currently) - self.update_minutely = Throttle(interval)(self._update_minutely) - self.update_hourly = Throttle(interval)(self._update_hourly) - self.update_daily = Throttle(interval)(self._update_daily) - self.update_alerts = Throttle(interval)(self._update_alerts) - - def _update(self): - """Get the latest data from Dark Sky.""" - try: - self.data = forecastio.load_forecast( - self._api_key, - self.latitude, - self.longitude, - units=self.units, - lang=self.language, - ) - if self._connect_error: - self._connect_error = False - _LOGGER.info("Reconnected to Dark Sky") - except (ConnectError, HTTPError, Timeout, ValueError) as error: - if not self._connect_error: - self._connect_error = True - _LOGGER.error("Unable to connect to Dark Sky: %s", error) - self.data = None - self.unit_system = self.data and self.data.json["flags"]["units"] - - def _update_currently(self): - """Update currently data.""" - self.data_currently = self.data and self.data.currently() - - def _update_minutely(self): - """Update minutely data.""" - self.data_minutely = self.data and self.data.minutely() - - def _update_hourly(self): - """Update hourly data.""" - self.data_hourly = self.data and self.data.hourly() - - def _update_daily(self): - """Update daily data.""" - self.data_daily = self.data and self.data.daily() - - def _update_alerts(self): - """Update alerts data.""" - self.data_alerts = self.data and self.data.alerts() diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py deleted file mode 100644 index 25672908670..00000000000 --- a/homeassistant/components/darksky/weather.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Support for retrieving meteorological data from Dark Sky.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import forecastio -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -import voluptuous as vol - -from homeassistant.components.weather import ( - ATTR_CONDITION_CLEAR_NIGHT, - ATTR_CONDITION_CLOUDY, - ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, - ATTR_CONDITION_LIGHTNING, - ATTR_CONDITION_PARTLYCLOUDY, - ATTR_CONDITION_RAINY, - ATTR_CONDITION_SNOWY, - ATTR_CONDITION_SNOWY_RAINY, - ATTR_CONDITION_SUNNY, - ATTR_CONDITION_WINDY, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - PLATFORM_SCHEMA, - WeatherEntity, -) -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, - UnitOfLength, - UnitOfPrecipitationDepth, - UnitOfPressure, - UnitOfSpeed, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -from homeassistant.util.dt import utc_from_timestamp - -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Powered by Dark Sky" - -FORECAST_MODE = ["hourly", "daily"] - -MAP_CONDITION = { - "clear-day": ATTR_CONDITION_SUNNY, - "clear-night": ATTR_CONDITION_CLEAR_NIGHT, - "rain": ATTR_CONDITION_RAINY, - "snow": ATTR_CONDITION_SNOWY, - "sleet": ATTR_CONDITION_SNOWY_RAINY, - "wind": ATTR_CONDITION_WINDY, - "fog": ATTR_CONDITION_FOG, - "cloudy": ATTR_CONDITION_CLOUDY, - "partly-cloudy-day": ATTR_CONDITION_PARTLYCLOUDY, - "partly-cloudy-night": ATTR_CONDITION_PARTLYCLOUDY, - "hail": ATTR_CONDITION_HAIL, - "thunderstorm": ATTR_CONDITION_LIGHTNING, - "tornado": None, -} - -CONF_UNITS = "units" - -DEFAULT_NAME = "Dark Sky" - -PLATFORM_SCHEMA = vol.All( - cv.removed(CONF_UNITS), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), - vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } - ), -) - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dark Sky weather.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config.get(CONF_NAME) - mode = config.get(CONF_MODE) - - units = "si" - dark_sky = DarkSkyData(config.get(CONF_API_KEY), latitude, longitude, units) - - add_entities([DarkSkyWeather(name, dark_sky, mode)], True) - - -class DarkSkyWeather(WeatherEntity): - """Representation of a weather condition.""" - - _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - _attr_native_pressure_unit = UnitOfPressure.MBAR - _attr_native_temperature_unit = UnitOfTemperature.CELSIUS - _attr_native_visibility_unit = UnitOfLength.KILOMETERS - _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - - def __init__(self, name, dark_sky, mode): - """Initialize Dark Sky weather.""" - self._name = name - self._dark_sky = dark_sky - self._mode = mode - - self._ds_data = None - self._ds_currently = None - self._ds_hourly = None - self._ds_daily = None - - @property - def available(self) -> bool: - """Return if weather data is available from Dark Sky.""" - return self._ds_data is not None - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_temperature(self): - """Return the temperature.""" - return self._ds_currently.get("temperature") - - @property - def humidity(self): - """Return the humidity.""" - return round(self._ds_currently.get("humidity") * 100.0, 2) - - @property - def native_wind_speed(self): - """Return the wind speed.""" - return self._ds_currently.get("windSpeed") - - @property - def wind_bearing(self): - """Return the wind bearing.""" - return self._ds_currently.get("windBearing") - - @property - def ozone(self): - """Return the ozone level.""" - return self._ds_currently.get("ozone") - - @property - def native_pressure(self): - """Return the pressure.""" - return self._ds_currently.get("pressure") - - @property - def native_visibility(self): - """Return the visibility.""" - return self._ds_currently.get("visibility") - - @property - def condition(self): - """Return the weather condition.""" - return MAP_CONDITION.get(self._ds_currently.get("icon")) - - @property - def forecast(self): - """Return the forecast array.""" - - # Per conversation with Joshua Reyes of Dark Sky, to get the total - # forecasted precipitation, you have to multiple the intensity by - # the hours for the forecast interval - def calc_precipitation(intensity, hours): - amount = None - if intensity is not None: - amount = round((intensity * hours), 1) - return amount if amount > 0 else None - - data = None - - if self._mode == "daily": - data = [ - { - ATTR_FORECAST_TIME: utc_from_timestamp( - entry.d.get("time") - ).isoformat(), - ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperatureHigh"), - ATTR_FORECAST_NATIVE_TEMP_LOW: entry.d.get("temperatureLow"), - ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( - entry.d.get("precipIntensity"), 24 - ), - ATTR_FORECAST_NATIVE_WIND_SPEED: entry.d.get("windSpeed"), - ATTR_FORECAST_WIND_BEARING: entry.d.get("windBearing"), - ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), - } - for entry in self._ds_daily.data - ] - else: - data = [ - { - ATTR_FORECAST_TIME: utc_from_timestamp( - entry.d.get("time") - ).isoformat(), - ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperature"), - ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( - entry.d.get("precipIntensity"), 1 - ), - ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), - } - for entry in self._ds_hourly.data - ] - - return data - - def update(self) -> None: - """Get the latest data from Dark Sky.""" - self._dark_sky.update() - - self._ds_data = self._dark_sky.data - currently = self._dark_sky.currently - self._ds_currently = currently.d if currently else {} - self._ds_hourly = self._dark_sky.hourly - self._ds_daily = self._dark_sky.daily - - -class DarkSkyData: - """Get the latest data from Dark Sky.""" - - def __init__(self, api_key, latitude, longitude, units): - """Initialize the data object.""" - self._api_key = api_key - self.latitude = latitude - self.longitude = longitude - self.requested_units = units - - self.data = None - self.currently = None - self.hourly = None - self.daily = None - self._connect_error = False - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from Dark Sky.""" - try: - self.data = forecastio.load_forecast( - self._api_key, self.latitude, self.longitude, units=self.requested_units - ) - self.currently = self.data.currently() - self.hourly = self.data.hourly() - self.daily = self.data.daily() - if self._connect_error: - self._connect_error = False - _LOGGER.info("Reconnected to Dark Sky") - except (ConnectError, HTTPError, Timeout, ValueError) as error: - if not self._connect_error: - self._connect_error = True - _LOGGER.error("Unable to connect to Dark Sky. %s", error) - self.data = None diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 6a7133b9483..10725cd0392 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -348,7 +348,7 @@ class ScannerEntity(BaseTrackerEntity): self.mac_address, self.unique_id, ) - if self.is_connected: + if self.is_connected and self.ip_address: _async_connected_device_registered( hass, self.mac_address, @@ -405,7 +405,7 @@ class ScannerEntity(BaseTrackerEntity): """Return the device state attributes.""" attr: dict[str, StateType] = {} attr.update(super().state_attributes) - if self.ip_address is not None: + if self.ip_address: attr[ATTR_IP] = self.ip_address if self.mac_address is not None: attr[ATTR_MAC] = self.mac_address diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index af70939ee6b..e27ff57f03f 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -25,10 +25,11 @@ from homeassistant.const import ( CONF_MAC, CONF_NAME, DEVICE_DEFAULT_NAME, + EVENT_HOMEASSISTANT_STOP, STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_per_platform, @@ -216,7 +217,7 @@ async def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> No discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) # Clean up stale devices - async_track_utc_time_change( + cancel_update_stale = async_track_utc_time_change( hass, tracker.async_update_stale, second=range(0, 60, 5) ) @@ -235,6 +236,16 @@ async def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> No # restore await tracker.async_setup_tracked_device() + @callback + def _on_hass_stop(_: Event) -> None: + """Cleanup when Home Assistant stops. + + Cancel the async_update_stale schedule. + """ + cancel_update_stale() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) + @attr.s class DeviceTrackerPlatform: @@ -356,6 +367,27 @@ async def async_create_platform_type( return DeviceTrackerPlatform(p_type, platform, p_config) +def _load_device_names_and_attributes( + scanner: DeviceScanner, + device_name_uses_executor: bool, + extra_attributes_uses_executor: bool, + seen: set[str], + found_devices: list[str], +) -> tuple[dict[str, str | None], dict[str, dict[str, Any]]]: + """Load device names and attributes in a single executor job.""" + host_name_by_mac: dict[str, str | None] = {} + extra_attributes_by_mac: dict[str, dict[str, Any]] = {} + for mac in found_devices: + if device_name_uses_executor and mac not in seen: + host_name_by_mac[mac] = scanner.get_device_name(mac) + if extra_attributes_uses_executor: + try: + extra_attributes_by_mac[mac] = scanner.get_extra_attributes(mac) + except NotImplementedError: + extra_attributes_by_mac[mac] = {} + return host_name_by_mac, extra_attributes_by_mac + + @callback def async_setup_scanner_platform( hass: HomeAssistant, @@ -373,7 +405,7 @@ def async_setup_scanner_platform( scanner.hass = hass # Initial scan of each mac we also tell about host name for config - seen: Any = set() + seen: set[str] = set() async def async_device_tracker_scan(now: datetime | None) -> None: """Handle interval matches.""" @@ -391,15 +423,42 @@ def async_setup_scanner_platform( async with update_lock: found_devices = await scanner.async_scan_devices() + device_name_uses_executor = ( + scanner.async_get_device_name.__func__ # type: ignore[attr-defined] + is DeviceScanner.async_get_device_name + ) + extra_attributes_uses_executor = ( + scanner.async_get_extra_attributes.__func__ # type: ignore[attr-defined] + is DeviceScanner.async_get_extra_attributes + ) + host_name_by_mac: dict[str, str | None] = {} + extra_attributes_by_mac: dict[str, dict[str, Any]] = {} + if device_name_uses_executor or extra_attributes_uses_executor: + ( + host_name_by_mac, + extra_attributes_by_mac, + ) = await hass.async_add_executor_job( + _load_device_names_and_attributes, + scanner, + device_name_uses_executor, + extra_attributes_uses_executor, + seen, + found_devices, + ) + for mac in found_devices: if mac in seen: host_name = None else: - host_name = await scanner.async_get_device_name(mac) + host_name = host_name_by_mac.get( + mac, await scanner.async_get_device_name(mac) + ) seen.add(mac) try: - extra_attributes = await scanner.async_get_extra_attributes(mac) + extra_attributes = extra_attributes_by_mac.get( + mac, await scanner.async_get_extra_attributes(mac) + ) except NotImplementedError: extra_attributes = {} @@ -423,14 +482,24 @@ def async_setup_scanner_platform( hass.async_create_task(async_see_device(**kwargs)) - async_track_time_interval( + cancel_legacy_scan = async_track_time_interval( hass, async_device_tracker_scan, interval, - f"device_tracker {platform} legacy scan", + name=f"device_tracker {platform} legacy scan", ) hass.async_create_task(async_device_tracker_scan(None)) + @callback + def _on_hass_stop(_: Event) -> None: + """Cleanup when Home Assistant stops. + + Cancel the legacy scan. + """ + cancel_legacy_scan() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) + async def get_tracker(hass: HomeAssistant, config: ConfigType) -> DeviceTracker: """Create a tracker.""" diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index ea5a5fb79ad..7f41d2c1d3d 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -77,7 +77,7 @@ SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class DhcpServiceInfo(BaseServiceInfo): """Prepared info from dhcp entries.""" @@ -260,7 +260,10 @@ class NetworkWatcher(WatcherBase): """Start scanning for new devices on the network.""" self._discover_hosts = DiscoverHosts() self._unsub = async_track_time_interval( - self.hass, self.async_start_discover, SCAN_INTERVAL, "DHCP network watcher" + self.hass, + self.async_start_discover, + SCAN_INTERVAL, + name="DHCP network watcher", ) self.async_start_discover() diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 2e1d758746d..e65966fbaa2 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.15"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.4.16"] } diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index ddf149d680f..33bba375fd3 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -48,49 +48,49 @@ class DSMRReaderSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_delivered_1", - name="Low tariff usage", + translation_key="low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_1", - name="Low tariff returned", + translation_key="low_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_delivered_2", - name="High tariff usage", + translation_key="high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_2", - name="High tariff returned", + translation_key="high_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_delivered", - name="Current power usage", + translation_key="current_power_usage", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_returned", - name="Current power return", + translation_key="current_power_return", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_delivered_l1", - name="Current power usage L1", + translation_key="current_power_usage_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -98,7 +98,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_delivered_l2", - name="Current power usage L2", + translation_key="current_power_usage_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -106,7 +106,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_delivered_l3", - name="Current power usage L3", + translation_key="current_power_usage_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -114,7 +114,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_returned_l1", - name="Current power return L1", + translation_key="current_power_return_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -122,7 +122,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_returned_l2", - name="Current power return L2", + translation_key="current_power_return_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -130,7 +130,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_returned_l3", - name="Current power return L3", + translation_key="current_power_return_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -138,7 +138,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/extra_device_delivered", - name="Gas meter usage", + translation_key="gas_meter_usage", entity_registry_enabled_default=False, icon="mdi:fire", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -146,7 +146,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l1", - name="Current voltage L1", + translation_key="current_voltage_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -154,7 +154,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l2", - name="Current voltage L2", + translation_key="current_voltage_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -162,7 +162,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l3", - name="Current voltage L3", + translation_key="current_voltage_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -170,7 +170,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_power_current_l1", - name="Phase power current L1", + translation_key="phase_power_current_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -178,7 +178,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_power_current_l2", - name="Phase power current L2", + translation_key="phase_power_current_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -186,7 +186,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_power_current_l3", - name="Phase power current L3", + translation_key="phase_power_current_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -194,384 +194,386 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/timestamp", - name="Telegram timestamp", + translation_key="telegram_timestamp", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/delivered", - name="Gas usage", + translation_key="gas_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", - name="Current gas usage", + translation_key="current_gas_usage", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/read_at", - name="Gas meter read", + translation_key="gas_meter_read", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1", - name="Low tariff usage (daily)", + translation_key="daily_low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2", - name="High tariff usage (daily)", + translation_key="daily_high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_returned", - name="Low tariff return (daily)", + translation_key="daily_low_tariff_return", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_returned", - name="High tariff return (daily)", + translation_key="daily_high_tariff_return", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_merged", - name="Power usage total (daily)", + translation_key="daily_power_usage_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_returned_merged", - name="Power return total (daily)", + translation_key="daily_power_return_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_cost", - name="Low tariff cost (daily)", + translation_key="daily_low_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_cost", - name="High tariff cost (daily)", + translation_key="daily_high_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_cost_merged", - name="Power total cost (daily)", + translation_key="daily_power_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas", - name="Gas usage (daily)", + translation_key="daily_gas_usage", icon="mdi:counter", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas_cost", - name="Gas cost", + translation_key="gas_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/total_cost", - name="Total cost", + translation_key="total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", - name="Low tariff delivered price", + translation_key="low_tariff_delivered_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", - name="High tariff delivered price", + translation_key="high_tariff_delivered_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", - name="Low tariff returned price", + translation_key="low_tariff_returned_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", - name="High tariff returned price", + translation_key="high_tariff_returned_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", - name="Gas price", + translation_key="gas_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_M3, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", - name="Current day fixed cost", + translation_key="current_day_fixed_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/dsmr_version", - name="DSMR version", + translation_key="dsmr_version", entity_registry_enabled_default=False, icon="mdi:alert-circle", state=dsmr_transform, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/electricity_tariff", - name="Electricity tariff", + translation_key="electricity_tariff", + device_class=SensorDeviceClass.ENUM, + options=["low", "high"], icon="mdi:flash", state=tariff_transform, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/power_failure_count", - name="Power failure count", + translation_key="power_failure_count", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/long_power_failure_count", - name="Long power failure count", + translation_key="long_power_failure_count", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l1", - name="Voltage sag L1", + translation_key="voltage_sag_l1", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l2", - name="Voltage sag L2", + translation_key="voltage_sag_l2", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l3", - name="Voltage sag L3", + translation_key="voltage_sag_l3", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l1", - name="Voltage swell L1", + translation_key="voltage_swell_l1", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l2", - name="Voltage swell L2", + translation_key="voltage_swell_l2", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l3", - name="Voltage swell L3", + translation_key="voltage_swell_l3", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/rejected_telegrams", - name="Rejected telegrams", + translation_key="rejected_telegrams", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1", - name="Current month low tariff usage", + translation_key="current_month_low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2", - name="Current month high tariff usage", + translation_key="current_month_high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_returned", - name="Current month low tariff returned", + translation_key="current_month_low_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_returned", - name="Current month high tariff returned", + translation_key="current_month_high_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_merged", - name="Current month power usage total", + translation_key="current_month_power_usage_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_returned_merged", - name="Current month power return total", + translation_key="current_month_power_return_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_cost", - name="Current month low tariff cost", + translation_key="current_month_low_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_cost", - name="Current month high tariff cost", + translation_key="current_month_high_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_cost_merged", - name="Current month power total cost", + translation_key="current_month_power_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas", - name="Current month gas usage", + translation_key="current_month_gas_usage", icon="mdi:counter", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas_cost", - name="Current month gas cost", + translation_key="current_month_gas_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/fixed_cost", - name="Current month fixed cost", + translation_key="current_month_fixed_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/total_cost", - name="Current month total cost", + translation_key="current_month_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1", - name="Current year low tariff usage", + translation_key="current_year_low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2", - name="Current year high tariff usage", + translation_key="current_year_high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_returned", - name="Current year low tariff returned", + translation_key="current_year_low_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_returned", - name="Current year high tariff returned", + translation_key="current_year_high_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_merged", - name="Current year power usage total", + translation_key="current_year_power_usage_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_returned_merged", - name="Current year power returned total", + translation_key="current_year_power_returned_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_cost", - name="Current year low tariff cost", + translation_key="current_year_low_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_cost", - name="Current year high tariff cost", + translation_key="current_year_high_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_cost_merged", - name="Current year power total cost", + translation_key="current_year_power_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas", - name="Current year gas usage", + translation_key="current_year_gas_usage", icon="mdi:counter", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas_cost", - name="Current year gas cost", + translation_key="current_year_gas_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/fixed_cost", - name="Current year fixed cost", + translation_key="current_year_fixed_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/total_cost", - name="Current year total cost", + translation_key="current_year_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/quarter-hour-peak-electricity/average_delivered", - name="Previous quarter-hour peak usage", + translation_key="previous_quarter_hour_peak_usage", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/quarter-hour-peak-electricity/read_at_start", - name="Quarter-hour peak start time", + translation_key="quarter_hour_peak_start_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/quarter-hour-peak-electricity/read_at_end", - name="Quarter-hour peak end time", + translation_key="quarter_hour_peak_end_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 28dc0abb2df..c618995ed45 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -23,6 +23,7 @@ async def async_setup_entry( class DSMRSensor(SensorEntity): """Representation of a DSMR sensor that is updated via MQTT.""" + _attr_has_entity_name = True entity_description: DSMRReaderSensorEntityDescription def __init__( diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 73c4ac04402..fce274e8917 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -8,5 +8,256 @@ "description": "Make sure to configure the 'split topic' data sources in DSMR Reader." } } + }, + "entity": { + "sensor": { + "low_tariff_usage": { + "name": "Low tariff usage" + }, + "low_tariff_returned": { + "name": "Low tariff returned" + }, + "high_tariff_usage": { + "name": "High tariff usage" + }, + "high_tariff_returned": { + "name": "High tariff returned" + }, + "current_power_usage": { + "name": "Current power usage" + }, + "current_power_return": { + "name": "Current power return" + }, + "current_power_usage_l1": { + "name": "Current power usage L1" + }, + "current_power_usage_l2": { + "name": "Current power usage L2" + }, + "current_power_usage_l3": { + "name": "Current power usage L3" + }, + "current_power_return_l1": { + "name": "Current power return L1" + }, + "current_power_return_l2": { + "name": "Current power return L2" + }, + "current_power_return_l3": { + "name": "Current power return L3" + }, + "gas_meter_usage": { + "name": "Gas meter usage" + }, + "current_voltage_l1": { + "name": "Current voltage L1" + }, + "current_voltage_l2": { + "name": "Current voltage L2" + }, + "current_voltage_l3": { + "name": "Current voltage L3" + }, + "phase_power_current_l1": { + "name": "Phase power current L1" + }, + "phase_power_current_l2": { + "name": "Phase power current L2" + }, + "phase_power_current_l3": { + "name": "Phase power current L3" + }, + "telegram_timestamp": { + "name": "Telegram timestamp" + }, + "gas_usage": { + "name": "Gas usage" + }, + "current_gas_usage": { + "name": "Current gas usage" + }, + "gas_meter_read": { + "name": "Gas meter read" + }, + "daily_low_tariff_usage": { + "name": "Low tariff usage (daily)" + }, + "daily_high_tariff_usage": { + "name": "High tariff usage (daily)" + }, + "daily_low_tariff_return": { + "name": "Low tariff return (daily)" + }, + "daily_high_tariff_return": { + "name": "High tariff return (daily)" + }, + "daily_power_usage_total": { + "name": "Power usage total (daily)" + }, + "daily_power_return_total": { + "name": "Power return total (daily)" + }, + "daily_low_tariff_cost": { + "name": "Low tariff cost (daily)" + }, + "daily_high_tariff_cost": { + "name": "High tariff cost (daily)" + }, + "daily_power_total_cost": { + "name": "Power total cost (daily)" + }, + "daily_gas_usage": { + "name": "Gas usage (daily)" + }, + "gas_cost": { + "name": "Gas cost" + }, + "total_cost": { + "name": "Total cost" + }, + "low_tariff_delivered_price": { + "name": "Low tariff delivered price" + }, + "high_tariff_delivered_price": { + "name": "High tariff delivered price" + }, + "low_tariff_returned_price": { + "name": "Low tariff returned price" + }, + "high_tariff_returned_price": { + "name": "High tariff returned price" + }, + "gas_price": { + "name": "Gas Price" + }, + "current_day_fixed_cost": { + "name": "Current day fixed cost" + }, + "dsmr_version": { + "name": "DSMR version" + }, + "electricity_tariff": { + "name": "Electricity tariff", + "state": { + "low": "Low", + "high": "High" + } + }, + "power_failure_count": { + "name": "Power failure count" + }, + "long_power_failure_count": { + "name": "Long power failure count" + }, + "voltage_sag_l1": { + "name": "Voltage sag L1" + }, + "voltage_sag_l2": { + "name": "Voltage sag L2" + }, + "voltage_sag_l3": { + "name": "Voltage sag L3" + }, + "voltage_swell_l1": { + "name": "Voltage swell L1" + }, + "voltage_swell_l2": { + "name": "Voltage swell L2" + }, + "voltage_swell_l3": { + "name": "Voltage swell L3" + }, + "rejected_telegrams": { + "name": "Rejected telegrams" + }, + "current_month_low_tariff_usage": { + "name": "Current month low tariff usage" + }, + "current_month_high_tariff_usage": { + "name": "Current month high tariff usage" + }, + "current_month_low_tariff_returned": { + "name": "Current month low tariff returned" + }, + "current_month_high_tariff_returned": { + "name": "Current month high tariff returned" + }, + "current_month_power_usage_total": { + "name": "Current month power usage total" + }, + "current_month_power_return_total": { + "name": "Current month power return total" + }, + "current_month_low_tariff_cost": { + "name": "Current month low tariff cost" + }, + "current_month_high_tariff_cost": { + "name": "Current month high tariff cost" + }, + "current_month_power_total_cost": { + "name": "Current month power total cost" + }, + "current_month_gas_usage": { + "name": "Current month gas usage" + }, + "current_month_gas_cost": { + "name": "Current month gas cost" + }, + "current_month_fixed_cost": { + "name": "Current month fixed cost" + }, + "current_month_total_cost": { + "name": "Current month total cost" + }, + "current_year_low_tariff_usage": { + "name": "Current year low tariff usage" + }, + "current_year_high_tariff_usage": { + "name": "Current year high tariff usage" + }, + "current_year_low_tariff_returned": { + "name": "Current year low tariff returned" + }, + "current_year_high_tariff_returned": { + "name": "Current year high tariff returned" + }, + "current_year_power_usage_total": { + "name": "Current year power usage total" + }, + "current_year_power_returned_total": { + "name": "Current year power returned total" + }, + "current_year_low_tariff_cost": { + "name": "Current year low tariff cost" + }, + "current_year_high_tariff_cost": { + "name": "Current year high tariff cost" + }, + "current_year_power_total_cost": { + "name": "Current year power total cost" + }, + "current_year_gas_usage": { + "name": "Current year gas usage" + }, + "current_year_gas_cost": { + "name": "Current year gas cost" + }, + "current_year_fixed_cost": { + "name": "Current year fixed cost" + }, + "current_year_total_cost": { + "name": "Current year total cost" + }, + "previous_quarter_hour_peak_usage": { + "name": "Previous quarter-hour peak usage" + }, + "quarter_hour_peak_start_time": { + "name": "Quarter-hour peak start time" + }, + "quarter_hour_peak_end_time": { + "name": "Quarter-hour peak end time" + } + } } } diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 60d058220ab..b50bd604763 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -32,7 +32,7 @@ CONF_STOP_ID = "stopid" CONF_ROUTE = "route" DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) TIME_STR_FORMAT = "%H:%M" @@ -77,6 +77,7 @@ class DublinPublicTransportSensor(SensorEntity): """Implementation of an Dublin public transport sensor.""" _attr_attribution = "Data provided by data.dublinked.ie" + _attr_icon = "mdi:bus" def __init__(self, data, stop, route, name): """Initialize the sensor.""" @@ -118,11 +119,6 @@ class DublinPublicTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from opendata.ch and update the states.""" self.data.update() diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py new file mode 100644 index 00000000000..af8786f8d77 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/const.py @@ -0,0 +1,33 @@ +"""Constants for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +CONF_REGION_NAME: Final = "region_name" + +ATTR_REGION_NAME: Final = "region_name" +ATTR_REGION_ID: Final = "region_id" +ATTR_LAST_UPDATE: Final = "last_update" +ATTR_WARNING_COUNT: Final = "warning_count" + +API_ATTR_WARNING_NAME: Final = "event" +API_ATTR_WARNING_TYPE: Final = "event_code" +API_ATTR_WARNING_LEVEL: Final = "level" +API_ATTR_WARNING_HEADLINE: Final = "headline" +API_ATTR_WARNING_DESCRIPTION: Final = "description" +API_ATTR_WARNING_INSTRUCTION: Final = "instruction" +API_ATTR_WARNING_START: Final = "start_time" +API_ATTR_WARNING_END: Final = "end_time" +API_ATTR_WARNING_PARAMETERS: Final = "parameters" +API_ATTR_WARNING_COLOR: Final = "color" + +CURRENT_WARNING_SENSOR: Final = "current_warning_level" +ADVANCE_WARNING_SENSOR: Final = "advance_warning_level" + +DEFAULT_NAME: Final = "DWD-Weather-Warnings" +DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=15) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index a76b8eeee8d..2a22d5f8fb2 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,9 +1,9 @@ { "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "codeowners": ["@runningman84", "@stephan192", "@Hummel95"], + "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], - "requirements": ["dwdwfsapi==1.0.5"] + "requirements": ["dwdwfsapi==1.0.6"] } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 531eb1b3707..054d9e5ca8b 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -10,9 +10,6 @@ Wetterwarnungen (Stufe 1) """ from __future__ import annotations -from datetime import timedelta -import logging - from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol @@ -28,33 +25,28 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -ATTR_REGION_NAME = "region_name" -ATTR_REGION_ID = "region_id" -ATTR_LAST_UPDATE = "last_update" -ATTR_WARNING_COUNT = "warning_count" - -API_ATTR_WARNING_NAME = "event" -API_ATTR_WARNING_TYPE = "event_code" -API_ATTR_WARNING_LEVEL = "level" -API_ATTR_WARNING_HEADLINE = "headline" -API_ATTR_WARNING_DESCRIPTION = "description" -API_ATTR_WARNING_INSTRUCTION = "instruction" -API_ATTR_WARNING_START = "start_time" -API_ATTR_WARNING_END = "end_time" -API_ATTR_WARNING_PARAMETERS = "parameters" -API_ATTR_WARNING_COLOR = "color" - -DEFAULT_NAME = "DWD-Weather-Warnings" - -CONF_REGION_NAME = "region_name" - -CURRENT_WARNING_SENSOR = "current_warning_level" -ADVANCE_WARNING_SENSOR = "advance_warning_level" - -SCAN_INTERVAL = timedelta(minutes=15) - +from .const import ( + ADVANCE_WARNING_SENSOR, + API_ATTR_WARNING_COLOR, + API_ATTR_WARNING_DESCRIPTION, + API_ATTR_WARNING_END, + API_ATTR_WARNING_HEADLINE, + API_ATTR_WARNING_INSTRUCTION, + API_ATTR_WARNING_LEVEL, + API_ATTR_WARNING_NAME, + API_ATTR_WARNING_PARAMETERS, + API_ATTR_WARNING_START, + API_ATTR_WARNING_TYPE, + ATTR_LAST_UPDATE, + ATTR_REGION_ID, + ATTR_REGION_NAME, + ATTR_WARNING_COUNT, + CONF_REGION_NAME, + CURRENT_WARNING_SENSOR, + DEFAULT_NAME, + DEFAULT_SCAN_INTERVAL, + LOGGER, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -169,7 +161,7 @@ class DwdWeatherWarningsSensor(SensorEntity): def update(self) -> None: """Get the latest data from the DWD-Weather-Warnings API.""" - _LOGGER.debug( + LOGGER.debug( "Update requested for %s (%s) by %s", self._api.api.warncell_name, self._api.api.warncell_id, @@ -185,8 +177,8 @@ class WrappedDwDWWAPI: """Initialize a DWD-Weather-Warnings wrapper.""" self.api = api - @Throttle(SCAN_INTERVAL) + @Throttle(DEFAULT_SCAN_INTERVAL) def update(self): """Get the latest data from the DWD-Weather-Warnings API.""" self.api.update() - _LOGGER.debug("Update performed") + LOGGER.debug("Update performed") diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 68b874149a7..611c1b6ddfd 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -290,7 +290,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.2.0", + breaks_in_ha_version="2023.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index c2c2485d948..be255ee951d 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.29"] + "requirements": ["env_canada==0.5.31"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 192a19e480b..02a5054dd1d 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -5,12 +5,11 @@ from collections.abc import Callable import functools import logging import math -from typing import Any, Generic, NamedTuple, TypeVar, cast, overload +from typing import Any, Generic, NamedTuple, TypeVar, cast from aioesphomeapi import ( APIClient, APIConnectionError, - APIIntEnum, APIVersion, DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, @@ -64,6 +63,7 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" @@ -345,11 +345,19 @@ async def async_setup_entry( # noqa: C901 disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False - # Clear out the states so that we will always dispatch + # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects - for state_keys in entry_data.state.values(): - state_keys.clear() - entry_data.async_update_device_state(hass) + entry_data.stale_state = { + (type(entity_state), key) + for state_dict in entry_data.state.values() + for key, entity_state in state_dict.items() + } + if not hass.is_stopping: + # Avoid marking every esphome entity as unavailable on shutdown + # since it generates a lot of state changed events and database + # writes when we already know we're shutting down and the state + # will be cleared anyway. + entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" @@ -682,41 +690,6 @@ def esphome_state_property( return _wrapper -_EnumT = TypeVar("_EnumT", bound=APIIntEnum) -_ValT = TypeVar("_ValT") - - -class EsphomeEnumMapper(Generic[_EnumT, _ValT]): - """Helper class to convert between hass and esphome enum values.""" - - def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: - """Construct a EsphomeEnumMapper.""" - # Add none mapping - augmented_mapping: dict[ - _EnumT | None, _ValT | None - ] = mapping # type: ignore[assignment] - augmented_mapping[None] = None - - self._mapping = augmented_mapping - self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} - - @overload - def from_esphome(self, value: _EnumT) -> _ValT: - ... - - @overload - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - ... - - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - """Convert from an esphome int representation to a hass string.""" - return self._mapping[value] - - def from_hass(self, value: _ValT) -> _EnumT: - """Convert from a hass string to a esphome int representation.""" - return self._inverse[value] - - ICON_SCHEMA = vol.Schema(cv.icon) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index bfb3dbb8668..e40df234d58 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -54,12 +54,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper FAN_QUIET = "quiet" diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d7f25f319ac..7a6027f946b 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -70,6 +70,10 @@ class RuntimeEntryData: client: APIClient store: Store state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) + # When the disconnect callback is called, we mark all states + # as stale so we will always dispatch a state update when the + # device reconnects. This is the same format as state_subscriptions. + stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set) info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) # A second list of EntityInfo objects @@ -206,9 +210,11 @@ class RuntimeEntryData: """Distribute an update of state information to the target.""" key = state.key state_type = type(state) + stale_state = self.stale_state current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) - if current_state == state: + subscription_key = (state_type, key) + if current_state == state and subscription_key not in stale_state: _LOGGER.debug( "%s: ignoring duplicate update with and key %s: %s", self.name, @@ -222,8 +228,8 @@ class RuntimeEntryData: key, state, ) + stale_state.discard(subscription_key) current_state_by_type[key] = state - subscription_key = (state_type, key) if subscription_key in self.state_subscriptions: self.state_subscriptions[subscription_key]() diff --git a/homeassistant/components/esphome/enum_mapper.py b/homeassistant/components/esphome/enum_mapper.py new file mode 100644 index 00000000000..566f0bc503b --- /dev/null +++ b/homeassistant/components/esphome/enum_mapper.py @@ -0,0 +1,39 @@ +"""Helper class to convert between Home Assistant and ESPHome enum values.""" + +from typing import Generic, TypeVar, overload + +from aioesphomeapi import APIIntEnum + +_EnumT = TypeVar("_EnumT", bound=APIIntEnum) +_ValT = TypeVar("_ValT") + + +class EsphomeEnumMapper(Generic[_EnumT, _ValT]): + """Helper class to convert between hass and esphome enum values.""" + + def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: + """Construct a EsphomeEnumMapper.""" + # Add none mapping + augmented_mapping: dict[ + _EnumT | None, _ValT | None + ] = mapping # type: ignore[assignment] + augmented_mapping[None] = None + + self._mapping = augmented_mapping + self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} + + @overload + def from_esphome(self, value: _EnumT) -> _ValT: + ... + + @overload + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + ... + + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + """Convert from an esphome int representation to a hass string.""" + return self._mapping[value] + + def from_hass(self, value: _ValT) -> _EnumT: + """Convert from a hass string to a esphome int representation.""" + return self._inverse[value] diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 27952d36c60..01060630964 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -22,12 +22,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ac98592da9a..bf3e269221e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.6.0", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.6.1", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 673a90580e0..d818e040965 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -24,12 +24,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 7379be33da2..3ca8e0b9728 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -11,12 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 863096cb3b1..25a0bfaff7f 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -24,12 +24,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt from homeassistant.util.enum import try_parse_enum -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 6f290ccb293..7b2321e172e 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -80,9 +80,7 @@ DEFAULT_FILTER_TIME_CONSTANT = 10 NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" -FILTER_SCHEMA = vol.Schema( - {vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int)} -) +FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend( { @@ -383,9 +381,9 @@ class FilterState: except ValueError: self.state = state.state - def set_precision(self, precision: int) -> None: + def set_precision(self, precision: int | None) -> None: """Set precision of Number based states.""" - if isinstance(self.state, Number): + if precision is not None and isinstance(self.state, Number): value = round(float(self.state), precision) self.state = int(value) if precision == 0 else value @@ -417,8 +415,8 @@ class Filter: self, name: str, window_size: int | timedelta, - precision: int, entity: str, + precision: int | None, ) -> None: """Initialize common attributes. @@ -467,6 +465,7 @@ class Filter: filtered = self._filter_state(fstate) filtered.set_precision(self.filter_precision) + if self._store_raw: self.states.append(copy(FilterState(new_state))) else: @@ -485,8 +484,9 @@ class RangeFilter(Filter, SensorEntity): def __init__( self, + *, entity: str, - precision: int, + precision: int | None = None, lower_bound: float | None = None, upper_bound: float | None = None, ) -> None: @@ -495,7 +495,9 @@ class RangeFilter(Filter, SensorEntity): :param upper_bound: band upper bound :param lower_bound: band lower bound """ - super().__init__(FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision, entity) + super().__init__( + FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision=precision, entity=entity + ) self._lower_bound = lower_bound self._upper_bound = upper_bound self._stats_internal: Counter = Counter() @@ -539,13 +541,20 @@ class OutlierFilter(Filter, SensorEntity): """ def __init__( - self, window_size: int, precision: int, entity: str, radius: float + self, + *, + window_size: int, + entity: str, + radius: float, + precision: int | None = None, ) -> None: """Initialize Filter. :param radius: band radius """ - super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + super().__init__( + FILTER_NAME_OUTLIER, window_size, precision=precision, entity=entity + ) self._radius = radius self._stats_internal: Counter = Counter() self._store_raw = True @@ -579,10 +588,17 @@ class LowPassFilter(Filter, SensorEntity): """BASIC Low Pass Filter.""" def __init__( - self, window_size: int, precision: int, entity: str, time_constant: int + self, + *, + window_size: int, + entity: str, + time_constant: int, + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) + super().__init__( + FILTER_NAME_LOWPASS, window_size, precision=precision, entity=entity + ) self._time_constant = time_constant def _filter_state(self, new_state: FilterState) -> FilterState: @@ -610,16 +626,19 @@ class TimeSMAFilter(Filter, SensorEntity): def __init__( self, + *, window_size: timedelta, - precision: int, entity: str, type: str, # pylint: disable=redefined-builtin + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter. :param type: type of algorithm used to connect discrete values """ - super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_SMA, window_size, precision=precision, entity=entity + ) self._time_window = window_size self.last_leak: FilterState | None = None self.queue = deque[FilterState]() @@ -660,9 +679,13 @@ class ThrottleFilter(Filter, SensorEntity): One sample per window. """ - def __init__(self, window_size: int, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: int, entity: str, precision: None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_THROTTLE, window_size, precision=precision, entity=entity + ) self._only_numbers = False def _filter_state(self, new_state: FilterState) -> FilterState: @@ -683,9 +706,13 @@ class TimeThrottleFilter(Filter, SensorEntity): One sample per time period. """ - def __init__(self, window_size: timedelta, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: timedelta, entity: str, precision: int | None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_THROTTLE, window_size, precision=precision, entity=entity + ) self._time_window = window_size self._last_emitted_at: datetime | None = None self._only_numbers = False diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 234f03812fe..8091f8981e3 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -19,12 +19,10 @@ _LOGGER = logging.getLogger(__name__) ATTR_EXCHANGE_RATE = "Exchange rate" ATTR_TARGET = "Target currency" -ATTRIBUTION = "Data provided by the European Central Bank (ECB)" DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange rate" -ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(days=1) @@ -61,7 +59,8 @@ def setup_platform( class ExchangeRateSensor(SensorEntity): """Representation of a Exchange sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Data provided by the European Central Bank (ECB)" + _attr_icon = "mdi:currency-usd" def __init__(self, data, name, target): """Initialize the sensor.""" @@ -94,11 +93,6 @@ class ExchangeRateSensor(SensorEntity): ATTR_TARGET: self._target, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index a9b1ef61db5..a6e8183bcdb 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -51,5 +51,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux_led==0.28.36"] + "requirements": ["flux_led==0.28.37"] } diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 228b7d5935c..d2edb99e026 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -38,14 +38,14 @@ class FritzBinarySensorEntityDescription( SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( FritzBinarySensorEntityDescription( key="is_connected", - name="Connection", + translation_key="is_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda status, _: bool(status.is_connected), ), FritzBinarySensorEntityDescription( key="is_linked", - name="Link", + translation_key="is_linked", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda status, _: bool(status.is_linked), diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index a186d50960e..f732e32b75a 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -39,28 +39,28 @@ class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixi BUTTONS: Final = [ FritzButtonDescription( key="firmware_update", - name="Firmware Update", + translation_key="firmware_update", device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_firmware_update(), ), FritzButtonDescription( key="reboot", - name="Reboot", + translation_key="reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_reboot(), ), FritzButtonDescription( key="reconnect", - name="Reconnect", + translation_key="reconnect", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_reconnect(), ), FritzButtonDescription( key="cleanup", - name="Cleanup", + translation_key="cleanup", icon="mdi:broom", entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_cleanup(), @@ -86,6 +86,7 @@ class FritzButton(ButtonEntity): """Defines a Fritz!Box base button.""" entity_description: FritzButtonDescription + _attr_has_entity_name = True def __init__( self, @@ -97,11 +98,11 @@ class FritzButton(ButtonEntity): self.entity_description = description self.avm_wrapper = avm_wrapper - self._attr_name = f"{device_friendly_name} {description.name}" self._attr_unique_id = f"{self.avm_wrapper.unique_id}-{description.key}" self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, avm_wrapper.mac)} + connections={(CONNECTION_NETWORK_MAC, avm_wrapper.mac)}, + name=device_friendly_name, ) async def async_press(self) -> None: diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index f6025e773e0..89a51581bf7 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1043,7 +1043,6 @@ class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): ) self.entity_description = description self._device_name = device_name - self._attr_name = description.name self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" @property diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 1008426558f..b117218e23d 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/fritz", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection==1.11.0", "xmltodict==0.13.0"], + "requirements": ["fritzconnection==1.12.0", "xmltodict==0.13.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 4b15f3f92de..2b156046098 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -152,20 +152,20 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( FritzSensorEntityDescription( key="external_ip", - name="External IP", + translation_key="external_ip", icon="mdi:earth", value_fn=_retrieve_external_ip_state, ), FritzSensorEntityDescription( key="external_ipv6", - name="External IPv6", + translation_key="external_ipv6", icon="mdi:earth", value_fn=_retrieve_external_ipv6_state, is_suitable=lambda info: info.ipv6_active, ), FritzSensorEntityDescription( key="device_uptime", - name="Device Uptime", + translation_key="device_uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_device_uptime_state, @@ -173,14 +173,14 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="connection_uptime", - name="Connection Uptime", + translation_key="connection_uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_connection_uptime_state, ), FritzSensorEntityDescription( key="kb_s_sent", - name="Upload Throughput", + translation_key="kb_s_sent", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -189,7 +189,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="kb_s_received", - name="Download Throughput", + translation_key="kb_s_received", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -198,7 +198,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="max_kb_s_sent", - name="Max Connection Upload Throughput", + translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -207,7 +207,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="max_kb_s_received", - name="Max Connection Download Throughput", + translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -216,7 +216,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="gb_sent", - name="GB sent", + translation_key="gb_sent", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -225,7 +225,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="gb_received", - name="GB received", + translation_key="gb_received", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -234,7 +234,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_kb_s_sent", - name="Link Upload Throughput", + translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -242,7 +242,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_kb_s_received", - name="Link Download Throughput", + translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -250,7 +250,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_noise_margin_sent", - name="Link Upload Noise Margin", + translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", value_fn=_retrieve_link_noise_margin_sent_state, @@ -258,7 +258,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_noise_margin_received", - name="Link Download Noise Margin", + translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", value_fn=_retrieve_link_noise_margin_received_state, @@ -266,7 +266,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_attenuation_sent", - name="Link Upload Power Attenuation", + translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", value_fn=_retrieve_link_attenuation_sent_state, @@ -274,7 +274,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_attenuation_received", - name="Link Download Power Attenuation", + translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", value_fn=_retrieve_link_attenuation_received_state, diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 2d76479f2b9..45262d6f8ac 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -52,5 +52,39 @@ } } } + }, + "entity": { + "binary_sensor": { + "is_connected": { "name": "Connection" }, + "is_linked": { "name": "Link" } + }, + "button": { + "cleanup": { "name": "Cleanup" }, + "firmware_update": { "name": "Firmware update" }, + "reboot": { + "name": "[%key:component::button::entity_component::restart::name%]" + }, + "reconnect": { "name": "Reconnect" } + }, + "sensor": { + "connection_uptime": { "name": "Connection uptime" }, + "device_uptime": { "name": "Last restart" }, + "external_ip": { "name": "External IP" }, + "external_ipv6": { "name": "External IPv6" }, + "gb_received": { "name": "GB received" }, + "gb_sent": { "name": "GB sent" }, + "kb_s_received": { "name": "Download throughput" }, + "kb_s_sent": { "name": "Upload throughput" }, + "link_attenuation_received": { + "name": "Link download power attenuation" + }, + "link_attenuation_sent": { "name": "Link upload power attenuation" }, + "link_kb_s_received": { "name": "Link download throughput" }, + "link_kb_s_sent": { "name": "Link upload throughput" }, + "link_noise_margin_received": { "name": "Link download noise margin" }, + "link_noise_margin_sent": { "name": "Link upload noise margin" }, + "max_kb_s_received": { "name": "Max connection download throughput" }, + "max_kb_s_sent": { "name": "Max connection upload throughput" } + } } } diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 38f0e375e87..bd246dd914f 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -113,8 +113,8 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): self.ain = ain if entity_description is not None: + self._attr_has_entity_name = True self.entity_description = entity_description - self._attr_name = f"{self.data.name} {entity_description.name}" self._attr_unique_id = f"{ain}_{entity_description.key}" else: self._attr_name = self.data.name diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 35fd41240df..f87beb34079 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -40,14 +40,14 @@ class FritzBinarySensorEntityDescription( BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( FritzBinarySensorEntityDescription( key="alarm", - name="Alarm", + translation_key="alarm", device_class=BinarySensorDeviceClass.WINDOW, suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] is_on=lambda device: device.alert_state, # type: ignore[no-any-return] ), FritzBinarySensorEntityDescription( key="lock", - name="Button Lock on Device", + translation_key="lock", device_class=BinarySensorDeviceClass.LOCK, entity_category=EntityCategory.CONFIG, suitable=lambda device: device.lock is not None, @@ -55,7 +55,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( ), FritzBinarySensorEntityDescription( key="device_lock", - name="Button Lock via UI", + translation_key="device_lock", device_class=BinarySensorDeviceClass.LOCK, entity_category=EntityCategory.CONFIG, suitable=lambda device: device.device_lock is not None, @@ -87,17 +87,6 @@ class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity): entity_description: FritzBinarySensorEntityDescription - def __init__( - self, - coordinator: FritzboxDataUpdateCoordinator, - ain: str, - entity_description: FritzBinarySensorEntityDescription, - ) -> None: - """Initialize the FritzBox entity.""" - super().__init__(coordinator, ain, entity_description) - self._attr_name = f"{self.data.name} {entity_description.name}" - self._attr_unique_id = f"{ain}_{entity_description.key}" - @property def is_on(self) -> bool | None: """Return true if sensor is on.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index a048a7bba54..33b4b8d5152 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -91,7 +91,7 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( FritzSensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -101,7 +101,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -110,7 +110,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -119,7 +119,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="power_consumption", - name="Power Consumption", + translation_key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -128,7 +128,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="voltage", - name="Voltage", + translation_key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -137,7 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="electric_current", - name="Electric Current", + translation_key="electric_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -146,7 +146,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="total_energy", - name="Total Energy", + translation_key="total_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -156,7 +156,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( # Thermostat Sensors FritzSensorEntityDescription( key="comfort_temperature", - name="Comfort Temperature", + translation_key="comfort_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suitable=suitable_comfort_temperature, @@ -164,7 +164,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="eco_temperature", - name="Eco Temperature", + translation_key="eco_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suitable=suitable_eco_temperature, @@ -172,7 +172,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="nextchange_temperature", - name="Next Scheduled Temperature", + translation_key="nextchange_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suitable=suitable_nextchange_temperature, @@ -180,20 +180,20 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="nextchange_time", - name="Next Scheduled Change Time", + translation_key="nextchange_time", device_class=SensorDeviceClass.TIMESTAMP, suitable=suitable_nextchange_time, native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod), ), FritzSensorEntityDescription( key="nextchange_preset", - name="Next Scheduled Preset", + translation_key="nextchange_preset", suitable=suitable_nextchange_temperature, native_value=value_nextchange_preset, ), FritzSensorEntityDescription( key="scheduled_preset", - name="Current Scheduled Preset", + translation_key="scheduled_preset", suitable=suitable_nextchange_temperature, native_value=value_scheduled_preset, ), diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 738c454e237..0b4becd6ff7 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -36,5 +36,41 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "entity": { + "binary_sensor": { + "alarm": { "name": "Alarm" }, + "device_lock": { "name": "Button lock via UI" }, + "lock": { "name": "Button lock on device" } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "comfort_temperature": { "name": "Comfort temperature" }, + "eco_temperature": { "name": "Eco temperature" }, + "electric_current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "nextchange_preset": { "name": "Next scheduled preset" }, + "nextchange_temperature": { "name": "Next scheduled temperature" }, + "nextchange_time": { "name": "Next scheduled change time" }, + "power_consumption": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "scheduled_preset": { "name": "Current scheduled preset" }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "total_energy": { + "name": "[%key:component::sensor::entity_component::energy::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + } + } } } diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 9f1078b9b9d..cde955caa1e 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection==1.11.0"] + "requirements": ["fritzconnection==1.12.0"] } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index e7f93895370..4e706db032f 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -89,28 +89,24 @@ class FroniusSensorEntityDescription(SensorEntityDescription): INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="energy_day", - name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), FroniusSensorEntityDescription( key="energy_year", - name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), FroniusSensorEntityDescription( key="energy_total", - name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), FroniusSensorEntityDescription( key="frequency_ac", - name="Frequency AC", default_value=0, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -119,7 +115,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="current_ac", - name="Current AC", default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -127,7 +122,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="current_dc", - name="Current DC", default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -136,7 +130,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="current_dc_2", - name="Current DC 2", default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -145,7 +138,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_ac", - name="Power AC", default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -153,7 +145,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_ac", - name="Voltage AC", default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -162,7 +153,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_dc", - name="Voltage DC", default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -171,7 +161,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_dc_2", - name="Voltage DC 2", default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -181,28 +170,23 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ # device status entities FroniusSensorEntityDescription( key="inverter_state", - name="Inverter state", entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="error_code", - name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="status_code", - name="Status code", entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="led_state", - name="LED state", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( key="led_color", - name="LED color", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -211,19 +195,16 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ LOGGER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="co2_factor", - name="CO₂ factor", state_class=SensorStateClass.MEASUREMENT, icon="mdi:molecule-co2", ), FroniusSensorEntityDescription( key="cash_factor", - name="Grid export tariff", state_class=SensorStateClass.MEASUREMENT, icon="mdi:cash-plus", ), FroniusSensorEntityDescription( key="delivery_factor", - name="Grid import tariff", state_class=SensorStateClass.MEASUREMENT, icon="mdi:cash-minus", ), @@ -232,7 +213,6 @@ LOGGER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="current_ac_phase_1", - name="Current AC phase 1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -240,7 +220,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="current_ac_phase_2", - name="Current AC phase 2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -248,7 +227,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="current_ac_phase_3", - name="Current AC phase 3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -256,7 +234,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="energy_reactive_ac_consumed", - name="Energy reactive AC consumed", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", @@ -264,7 +241,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="energy_reactive_ac_produced", - name="Energy reactive AC produced", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", @@ -272,7 +248,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="energy_real_ac_minus", - name="Energy real AC minus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -280,7 +255,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="energy_real_ac_plus", - name="Energy real AC plus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -288,33 +262,28 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="energy_real_consumed", - name="Energy real consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), FroniusSensorEntityDescription( key="energy_real_produced", - name="Energy real produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), FroniusSensorEntityDescription( key="frequency_phase_average", - name="Frequency phase average", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="meter_location", - name="Meter location", entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="power_apparent_phase_1", - name="Power apparent phase 1", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -323,7 +292,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_apparent_phase_2", - name="Power apparent phase 2", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -332,7 +300,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_apparent_phase_3", - name="Power apparent phase 3", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -341,7 +308,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_apparent", - name="Power apparent", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -350,34 +316,29 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_factor_phase_1", - name="Power factor phase 1", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( key="power_factor_phase_2", - name="Power factor phase 2", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( key="power_factor_phase_3", - name="Power factor phase 3", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( key="power_factor", - name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="power_reactive_phase_1", - name="Power reactive phase 1", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -386,7 +347,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_reactive_phase_2", - name="Power reactive phase 2", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -395,7 +355,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_reactive_phase_3", - name="Power reactive phase 3", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -404,7 +363,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_reactive", - name="Power reactive", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -413,7 +371,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_real_phase_1", - name="Power real phase 1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -421,7 +378,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_real_phase_2", - name="Power real phase 2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -429,7 +385,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_real_phase_3", - name="Power real phase 3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -437,14 +392,12 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_real", - name="Power real", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="voltage_ac_phase_1", - name="Voltage AC phase 1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -452,7 +405,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_ac_phase_2", - name="Voltage AC phase 2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -460,7 +412,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_ac_phase_3", - name="Voltage AC phase 3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -468,7 +419,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_12", - name="Voltage AC phase 1-2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -476,7 +426,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_23", - name="Voltage AC phase 2-3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -484,7 +433,6 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_31", - name="Voltage AC phase 3-1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -495,38 +443,32 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="energy_real_ac_consumed", - name="Energy consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), FroniusSensorEntityDescription( key="power_real_ac", - name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="temperature_channel_1", - name="Temperature channel 1", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="error_code", - name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="state_code", - name="State code", entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="state_message", - name="State message", entity_category=EntityCategory.DIAGNOSTIC, ), ] @@ -534,7 +476,6 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="energy_day", - name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -542,7 +483,6 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="energy_year", - name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -550,7 +490,6 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="energy_total", - name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -558,12 +497,10 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="meter_mode", - name="Meter mode", entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="power_battery", - name="Power battery", default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -571,7 +508,6 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_grid", - name="Power grid", default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -579,7 +515,6 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_load", - name="Power load", default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -587,7 +522,6 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="power_photovoltaics", - name="Power photovoltaics", default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -595,7 +529,6 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="relative_autonomy", - name="Relative autonomy", default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -603,7 +536,6 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="relative_self_consumption", - name="Relative self consumption", default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -614,19 +546,16 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="capacity_maximum", - name="Capacity maximum", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="capacity_designed", - name="Capacity designed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), FroniusSensorEntityDescription( key="current_dc", - name="Current DC", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -634,7 +563,6 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_dc", - name="Voltage DC", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -642,7 +570,6 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_dc_maximum_cell", - name="Voltage DC maximum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -651,7 +578,6 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="voltage_dc_minimum_cell", - name="Voltage DC minimum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -660,14 +586,12 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="state_of_charge", - name="State of charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="temperature_cell", - name="Temperature cell", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -696,6 +620,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn ) self.solar_net_id = solar_net_id self._attr_native_value = self._get_entity_value() + self._attr_translation_key = self.entity_description.key def _device_data(self) -> dict[str, Any]: """Extract information for SolarNet device from coordinator data.""" diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 711e363eeba..4a0f96ed8e6 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -21,5 +21,219 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } + }, + "entity": { + "sensor": { + "energy_day": { + "name": "Energy day" + }, + "energy_year": { + "name": "Energy year" + }, + "energy_total": { + "name": "Total energy" + }, + "frequency_ac": { + "name": "[%key:component::sensor::entity_component::frequency::name%]" + }, + "current_ac": { + "name": "AC current" + }, + "current_dc": { + "name": "DC current" + }, + "current_dc_2": { + "name": "DC current 2" + }, + "power_ac": { + "name": "AC power" + }, + "voltage_ac": { + "name": "AC voltage" + }, + "voltage_dc": { + "name": "DC voltage" + }, + "voltage_dc_2": { + "name": "DC voltage 2" + }, + "inverter_state": { + "name": "Inverter state" + }, + "error_code": { + "name": "Error code" + }, + "status_code": { + "name": "Status code" + }, + "led_state": { + "name": "LED state" + }, + "led_color": { + "name": "LED color" + }, + "co2_factor": { + "name": "CO₂ factor" + }, + "cash_factor": { + "name": "Grid export tariff" + }, + "delivery_factor": { + "name": "Grid import tariff" + }, + "current_ac_phase_1": { + "name": "Current phase 1" + }, + "current_ac_phase_2": { + "name": "Current phase 2" + }, + "current_ac_phase_3": { + "name": "Current phase 3" + }, + "energy_reactive_ac_consumed": { + "name": "Reactive energy consumed" + }, + "energy_reactive_ac_produced": { + "name": "Reactive energy produced" + }, + "energy_real_ac_minus": { + "name": "Real energy minus" + }, + "energy_real_ac_plus": { + "name": "Real energy plus" + }, + "energy_real_consumed": { + "name": "Real energy consumed" + }, + "energy_real_produced": { + "name": "Real energy produced" + }, + "frequency_phase_average": { + "name": "Frequency phase average" + }, + "meter_location": { + "name": "Meter location" + }, + "power_apparent_phase_1": { + "name": "Apparent power phase 1" + }, + "power_apparent_phase_2": { + "name": "Apparent power phase 2" + }, + "power_apparent_phase_3": { + "name": "Apparent power phase 3" + }, + "power_apparent": { + "name": "[%key:component::sensor::entity_component::apparent_power::name%]" + }, + "power_factor_phase_1": { + "name": "Power factor phase 1" + }, + "power_factor_phase_2": { + "name": "Power factor phase 2" + }, + "power_factor_phase_3": { + "name": "Power factor phase 3" + }, + "power_factor": { + "name": "[%key:component::sensor::entity_component::power_factor::name%]" + }, + "power_reactive_phase_1": { + "name": "Reactive power phase 1" + }, + "power_reactive_phase_2": { + "name": "Reactive power phase 2" + }, + "power_reactive_phase_3": { + "name": "Reactive power phase 3" + }, + "power_reactive": { + "name": "Reactive power" + }, + "power_real_phase_1": { + "name": "Real power phase 1" + }, + "power_real_phase_2": { + "name": "Real power phase 2" + }, + "power_real_phase_3": { + "name": "Real power phase 3" + }, + "power_real": { + "name": "Real power" + }, + "voltage_ac_phase_1": { + "name": "Voltage phase 1" + }, + "voltage_ac_phase_2": { + "name": "Voltage phase 2" + }, + "voltage_ac_phase_3": { + "name": "Voltage phase 3" + }, + "voltage_ac_phase_to_phase_12": { + "name": "Voltage phase 1-2" + }, + "voltage_ac_phase_to_phase_23": { + "name": "Voltage phase 2-3" + }, + "voltage_ac_phase_to_phase_31": { + "name": "Voltage phase 3-1" + }, + "energy_real_ac_consumed": { + "name": "Energy consumed" + }, + "power_real_ac": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "temperature_channel_1": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "state_code": { + "name": "State code" + }, + "state_message": { + "name": "State message" + }, + "meter_mode": { + "name": "Meter mode" + }, + "power_battery": { + "name": "Power battery" + }, + "power_grid": { + "name": "Power grid" + }, + "power_load": { + "name": "Power load" + }, + "power_photovoltaics": { + "name": "Power photovoltaics" + }, + "relative_autonomy": { + "name": "Relative autonomy" + }, + "relative_self_consumption": { + "name": "Relative self consumption" + }, + "capacity_maximum": { + "name": "Maximum capacity " + }, + "capacity_designed": { + "name": "Designed capacity" + }, + "voltage_dc_maximum_cell": { + "name": "Maximum cell voltage" + }, + "voltage_dc_minimum_cell": { + "name": "Minimum cell voltage" + }, + "state_of_charge": { + "name": "State of charge" + }, + "temperature_cell": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + } + } } } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8c3fb8c1434..b1fd062032f 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230329.0"] + "requirements": ["home-assistant-frontend==20230406.1"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7cf4b7e7c60..f078cc074e9 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -60,7 +60,6 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, - name="AQI", value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, @@ -69,35 +68,34 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_C6H6, - name="C6H6", value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="c6h6", ), GiosSensorEntityDescription( key=ATTR_CO, - name="CO", value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO2, - name="NO2", value=lambda sensors: sensors.no2.value if sensors.no2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="no2", ), GiosSensorEntityDescription( key=ATTR_NO2, subkey="index", - name="NO2 index", value=lambda sensors: sensors.no2.index if sensors.no2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -106,17 +104,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_O3, - name="O3", value=lambda sensors: sensors.o3.value if sensors.o3 else None, suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="o3", ), GiosSensorEntityDescription( key=ATTR_O3, subkey="index", - name="O3 index", value=lambda sensors: sensors.o3.index if sensors.o3 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -125,17 +122,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM10, - name="PM10", value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm10", ), GiosSensorEntityDescription( key=ATTR_PM10, subkey="index", - name="PM10 index", value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -144,17 +140,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM25, - name="PM2.5", value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm25", ), GiosSensorEntityDescription( key=ATTR_PM25, subkey="index", - name="PM2.5 index", value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -163,17 +158,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_SO2, - name="SO2", value=lambda sensors: sensors.so2.value if sensors.so2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="so2", ), GiosSensorEntityDescription( key=ATTR_SO2, subkey="index", - name="SO2 index", value=lambda sensors: sensors.so2.index if sensors.so2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 53e7dd78a8f..391dd874426 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -26,6 +26,7 @@ "entity": { "sensor": { "aqi": { + "name": "[%key:component::sensor::entity_component::aqi::name%]", "state": { "very_bad": "Very bad", "bad": "Bad", @@ -35,7 +36,17 @@ "very_good": "Very good" } }, + "c6h6": { + "name": "Benzene" + }, + "co": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "no2": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, "no2_index": { + "name": "Nitrogen dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -45,7 +56,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "o3": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, "o3_index": { + "name": "Ozone index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -55,7 +70,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, "pm10_index": { + "name": "Particulate matter 10 μm index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -65,7 +84,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, "pm25_index": { + "name": "Particulate matter 2.5 μm index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -75,7 +98,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "so2": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, "so2_index": { + "name": "Sulphur dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 514cb9e0ad5..db5b189d5ea 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -23,7 +23,6 @@ ATTR_USERNAME = "username" DEFAULT_NAME = "Gitter messages" DEFAULT_ROOM = "home-assistant/home-assistant" -ICON = "mdi:message-cog" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -59,6 +58,8 @@ def setup_platform( class GitterSensor(SensorEntity): """Representation of a Gitter sensor.""" + _attr_icon = "mdi:message-cog" + def __init__(self, data, room, name, username): """Initialize the sensor.""" self._name = name @@ -93,11 +94,6 @@ class GitterSensor(SensorEntity): ATTR_MENTION: self._mention, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index cf55118a913..04e133248a6 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -45,7 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" api = get_api(hass, data) try: - await api.get_data("all") + await api.get_ha_sensor_data() except GlancesApiError as err: raise CannotConnect from err diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8ffd2a2da6e..01e498a8897 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -36,7 +36,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - await self.api.get_data("all") + return await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err - return self.api.data diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index b8b5d80a206..8b836fba3ea 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - STATE_UNAVAILABLE, Platform, UnitOfInformation, UnitOfTemperature, @@ -45,8 +44,8 @@ class GlancesSensorEntityDescription( """Describe Glances sensor entity.""" -SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( - GlancesSensorEntityDescription( +SENSOR_TYPES = { + ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", name_suffix="used percent", @@ -54,7 +53,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", @@ -63,7 +62,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", @@ -72,7 +71,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", @@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", @@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", @@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", @@ -106,7 +105,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", @@ -115,7 +114,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", @@ -124,42 +123,42 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", @@ -167,7 +166,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", @@ -175,7 +174,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", @@ -183,7 +182,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", @@ -191,7 +190,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", @@ -200,14 +199,14 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:battery", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", @@ -215,7 +214,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", @@ -224,21 +223,21 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), -) +} async def async_setup_entry( @@ -266,64 +265,40 @@ async def async_setup_entry( entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" ) - for description in SENSOR_TYPES: - if description.type == "fs": - # fs will provide a list of disks attached - for disk in coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {disk['mnt_point']} {description.name_suffix}", - f"{disk['mnt_point']}-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - disk["mnt_point"], - description, - ) - ) - elif description.type == "sensors": - # sensors will provide temp for different devices - for sensor in coordinator.data[description.type]: - if sensor["type"] == description.key: + for sensor_type, sensors in coordinator.data.items(): + if sensor_type in ["fs", "sensors", "raid"]: + for sensor_label, params in sensors.items(): + for param in params: + sensor_description = SENSOR_TYPES[(sensor_type, param)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor['label']} {description.name_suffix}", - f"{sensor['label']}-{description.key}", + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor["label"], - description, + sensor_label, + sensor_description, ) ) - elif description.type == "raid": - for raid_device in coordinator.data[description.type]: + else: + for sensor in sensors: + sensor_description = SENSOR_TYPES[(sensor_type, sensor)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {raid_device} {description.name_suffix}", - f"{raid_device}-{description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( - GlancesSensor(coordinator, name, raid_device, description) + GlancesSensor( + coordinator, + name, + "", + sensor_description, + ) ) - elif coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {description.name_suffix}", - f"-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - description, - ) - ) async_add_entities(entities) @@ -354,114 +329,10 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property - def native_value(self) -> StateType: # noqa: C901 + def native_value(self) -> StateType: """Return the state of the resources.""" - if (value := self.coordinator.data) is None: - return None - state: StateType = None - if self.entity_description.type == "fs": - for var in value["fs"]: - if var["mnt_point"] == self._sensor_name_prefix: - disk = var - break - if self.entity_description.key == "disk_free": - try: - state = round(disk["free"] / 1024**3, 1) - except KeyError: - state = round( - (disk["size"] - disk["used"]) / 1024**3, - 1, - ) - elif self.entity_description.key == "disk_use": - state = round(disk["used"] / 1024**3, 1) - elif self.entity_description.key == "disk_use_percent": - state = disk["percent"] - elif self.entity_description.key == "battery": - for sensor in value["sensors"]: - if ( - sensor["type"] == "battery" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "fan_speed": - for sensor in value["sensors"]: - if ( - sensor["type"] == "fan_speed" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_core": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_core" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_hdd": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_hdd" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "memory_use_percent": - state = value["mem"]["percent"] - elif self.entity_description.key == "memory_use": - state = round(value["mem"]["used"] / 1024**2, 1) - elif self.entity_description.key == "memory_free": - state = round(value["mem"]["free"] / 1024**2, 1) - elif self.entity_description.key == "swap_use_percent": - state = value["memswap"]["percent"] - elif self.entity_description.key == "swap_use": - state = round(value["memswap"]["used"] / 1024**3, 1) - elif self.entity_description.key == "swap_free": - state = round(value["memswap"]["free"] / 1024**3, 1) - elif self.entity_description.key == "processor_load": - # Windows systems don't provide load details - try: - state = value["load"]["min15"] - except KeyError: - state = value["cpu"]["total"] - elif self.entity_description.key == "process_running": - state = value["processcount"]["running"] - elif self.entity_description.key == "process_total": - state = value["processcount"]["total"] - elif self.entity_description.key == "process_thread": - state = value["processcount"]["thread"] - elif self.entity_description.key == "process_sleeping": - state = value["processcount"]["sleeping"] - elif self.entity_description.key == "cpu_use_percent": - state = value["quicklook"]["cpu"] - elif self.entity_description.key == "docker_active": - count = 0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - count += 1 - state = count - except KeyError: - state = count - elif self.entity_description.key == "docker_cpu_use": - cpu_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - cpu_use += container["cpu"]["total"] - state = round(cpu_use, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.key == "docker_memory_use": - mem_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - mem_use += container["memory"]["usage"] - state = round(mem_use / 1024**2, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.type == "raid": - for raid_device, raid in value["raid"].items(): - if raid_device == self._sensor_name_prefix: - state = raid[self.entity_description.key] + value = self.coordinator.data[self.entity_description.type] - return state + if isinstance(value.get(self._sensor_name_prefix), dict): + return value[self._sensor_name_prefix][self.entity_description.key] + return value[self.entity_description.key] diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 8dad8454d6b..f40d2253614 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.29"] + "requirements": ["goodwe==0.2.31"] } diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 25993760d80..e05a6f6fb97 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -285,17 +285,18 @@ async def async_setup_add_event_service( raise ValueError( "Missing required fields to set start or end date/datetime" ) - + event = Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ) + if location := call.data.get(EVENT_LOCATION): + event.location = location try: await calendar_service.async_create_event( call.data[EVENT_CALENDAR_ID], - Event( - summary=call.data[EVENT_SUMMARY], - description=call.data[EVENT_DESCRIPTION], - location=call.data[EVENT_LOCATION], - start=start, - end=end, - ), + event, ) except ApiException as err: raise HomeAssistantError(str(err)) from err diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 363b75c2c54..347e8444946 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -508,9 +508,10 @@ class GoogleCalendarEntity( "start": start, "end": end, EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION), - EVENT_LOCATION: kwargs.get(EVENT_LOCATION), } ) + if location := kwargs.get(EVENT_LOCATION): + event.location = location if rrule := kwargs.get(EVENT_RRULE): event.recurrence = [f"{RRULE_PREFIX}{rrule}"] @@ -597,18 +598,20 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> if start is None or end is None: raise ValueError("Missing required fields to set start or end date/datetime") + event = Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ) + if location := call.data.get(EVENT_LOCATION): + event.location = location try: await cast( CalendarSyncUpdateCoordinator, entity.coordinator ).sync.api.async_create_event( entity.calendar_id, - Event( - summary=call.data[EVENT_SUMMARY], - description=call.data[EVENT_DESCRIPTION], - location=call.data[EVENT_LOCATION], - start=start, - end=end, - ), + event, ) except ApiException as err: raise HomeAssistantError(str(err)) from err diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index d79cd105c83..f4177e8c300 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google/", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==4.1.2", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==4.1.4", "oauth2client==4.1.3"] } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b248ffbac22..3752574f31f 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -75,7 +75,6 @@ from homeassistant.util.percentage import ( from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( - CHALLENGE_ACK_NEEDED, CHALLENGE_FAILED_PIN_NEEDED, CHALLENGE_PIN_NEEDED, ERR_ALREADY_ARMED, @@ -2131,14 +2130,6 @@ def _verify_pin_challenge(data, state, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, state, challenge): - """Verify an ack challenge.""" - if not data.config.should_2fa(state): - return - if not challenge or not challenge.get("ack"): - raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) - - MEDIA_COMMAND_SUPPORT_MAPPING = { COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json index 6ba831442d6..169897bb402 100644 --- a/homeassistant/components/google_maps/manifest.json +++ b/homeassistant/components/google_maps/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_maps", "iot_class": "cloud_polling", "loggers": ["locationsharinglib"], - "requirements": ["locationsharinglib==4.1.5"] + "requirements": ["locationsharinglib==5.0.0"] } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index feabe59bf22..92999fc8f8a 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -63,8 +63,6 @@ from .const import ( ATTR_PASSWORD, ATTR_REPOSITORY, ATTR_SLUG, - ATTR_STARTED, - ATTR_STATE, ATTR_URL, ATTR_VERSION, DATA_KEY_ADDONS, @@ -862,20 +860,18 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED - ] - + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 29cb53de70f..48fd005fc32 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -22,7 +22,7 @@ from .handler import HassIO, HassioAPIError _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class HassioServiceInfo(BaseServiceInfo): """Prepared info from hassio entries.""" diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 36f2f8945c0..31367cd0c93 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations from datetime import datetime as dt, timedelta from http import HTTPStatus -import logging -import time from typing import cast from aiohttp import web @@ -12,68 +10,40 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView -from homeassistant.components.recorder import ( - DOMAIN as RECORDER_DOMAIN, - get_instance, - history, -) -from homeassistant.components.recorder.filters import ( - Filters, - extract_include_exclude_filter_conf, - merge_include_exclude_filters, - sqlalchemy_filter_from_include_exclude_conf, -) +from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import ( - INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, - convert_include_exclude_filter, -) +from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN from .helpers import entities_may_have_state_changes_after -from .models import HistoryConfig - -_LOGGER = logging.getLogger(__name__) CONF_ORDER = "use_include_order" +_ONE_DAY = timedelta(days=1) + CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(CONF_ORDER), - INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( - {vol.Optional(CONF_ORDER, default=False): cv.boolean} - ), - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.All( + INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( + {vol.Optional(CONF_ORDER, default=False): cv.boolean} + ), + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the history hooks.""" - conf = config.get(DOMAIN, {}) - recorder_conf = config.get(RECORDER_DOMAIN, {}) - history_conf = config.get(DOMAIN, {}) - recorder_filter = extract_include_exclude_filter_conf(recorder_conf) - logbook_filter = extract_include_exclude_filter_conf(history_conf) - merged_filter = merge_include_exclude_filters(recorder_filter, logbook_filter) - - possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) - - sqlalchemy_filter = None - entity_filter = None - if not possible_merged_entities_filter.empty_filter: - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(conf) - entity_filter = possible_merged_entities_filter - - hass.data[DOMAIN] = HistoryConfig(sqlalchemy_filter, entity_filter) - hass.http.register_view(HistoryPeriodView(sqlalchemy_filter)) + hass.http.register_view(HistoryPeriodView()) frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box") websocket_api.async_setup(hass) return True @@ -86,44 +56,42 @@ class HistoryPeriodView(HomeAssistantView): name = "api:history:view-period" extra_urls = ["/api/history/period/{datetime}"] - def __init__(self, filters: Filters | None) -> None: - """Initialize the history period view.""" - self.filters = filters - async def get( self, request: web.Request, datetime: str | None = None ) -> web.Response: """Return history over a period of time.""" datetime_ = None + query = request.query + if datetime and (datetime_ := dt_util.parse_datetime(datetime)) is None: return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) - now = dt_util.utcnow() + if not (entity_ids_str := query.get("filter_entity_id")) or not ( + entity_ids := entity_ids_str.strip().lower().split(",") + ): + return self.json_message( + "filter_entity_id is missing", HTTPStatus.BAD_REQUEST + ) - one_day = timedelta(days=1) + now = dt_util.utcnow() if datetime_: start_time = dt_util.as_utc(datetime_) else: - start_time = now - one_day + start_time = now - _ONE_DAY if start_time > now: return self.json([]) - if end_time_str := request.query.get("end_time"): + if end_time_str := query.get("end_time"): if end_time := dt_util.parse_datetime(end_time_str): end_time = dt_util.as_utc(end_time) else: return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) else: - end_time = start_time + one_day - entity_ids_str = request.query.get("filter_entity_id") - entity_ids = None - if entity_ids_str: - entity_ids = entity_ids_str.lower().split(",") - include_start_time_state = "skip_initial_state" not in request.query - significant_changes_only = ( - request.query.get("significant_changes_only", "1") != "0" - ) + end_time = start_time + _ONE_DAY + + include_start_time_state = "skip_initial_state" not in query + significant_changes_only = query.get("significant_changes_only", "1") != "0" minimal_response = "minimal_response" in request.query no_attributes = "no_attributes" in request.query @@ -159,33 +127,27 @@ class HistoryPeriodView(HomeAssistantView): hass: HomeAssistant, start_time: dt, end_time: dt, - entity_ids: list[str] | None, + entity_ids: list[str], include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, no_attributes: bool, ) -> web.Response: """Fetch significant stats from the database as json.""" - timer_start = time.perf_counter() - with session_scope(hass=hass, read_only=True) as session: - states = history.get_significant_states_with_session( - hass, - session, - start_time, - end_time, - entity_ids, - self.filters, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, + return self.json( + list( + history.get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + None, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + ).values() + ) ) - - if _LOGGER.isEnabledFor(logging.DEBUG): - elapsed = time.perf_counter() - timer_start - _LOGGER.debug( - "Extracted %d states in %fs", sum(map(len, states.values())), elapsed - ) - - return self.json(list(states.values())) diff --git a/homeassistant/components/history/models.py b/homeassistant/components/history/models.py deleted file mode 100644 index 3998d9f7e00..00000000000 --- a/homeassistant/components/history/models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Models for the history integration.""" -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.recorder.filters import Filters -from homeassistant.helpers.entityfilter import EntityFilter - - -@dataclass -class HistoryConfig: - """Configuration for the history integration.""" - - sqlalchemy_filter: Filters | None = None - entity_filter: EntityFilter | None = None diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index a761021de55..9c6cc12eb40 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -12,7 +12,6 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance, history -from homeassistant.components.recorder.filters import Filters from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import ( @@ -20,7 +19,6 @@ from homeassistant.const import ( COMPRESSED_STATE_LAST_CHANGED, COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE, - EVENT_STATE_CHANGED, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -30,7 +28,6 @@ from homeassistant.core import ( callback, is_callback, ) -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, @@ -38,9 +35,8 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util -from .const import DOMAIN, EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES +from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES from .helpers import entities_may_have_state_changes_after -from .models import HistoryConfig _LOGGER = logging.getLogger(__name__) @@ -69,7 +65,6 @@ def _ws_get_significant_states( start_time: dt, end_time: dt | None, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -84,7 +79,7 @@ def _ws_get_significant_states( start_time, end_time, entity_ids, - filters, + None, include_start_time_state, significant_changes_only, minimal_response, @@ -150,7 +145,6 @@ async def ws_get_history_during_period( significant_changes_only = msg["significant_changes_only"] minimal_response = msg["minimal_response"] - history_config: HistoryConfig = hass.data[DOMAIN] connection.send_message( await get_instance(hass).async_add_executor_job( @@ -160,7 +154,6 @@ async def ws_get_history_during_period( start_time, end_time, entity_ids, - history_config.sqlalchemy_filter, include_start_time_state, significant_changes_only, minimal_response, @@ -214,7 +207,6 @@ def _generate_historical_response( start_time: dt, end_time: dt, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -229,7 +221,7 @@ def _generate_historical_response( start_time, end_time, entity_ids, - filters, + None, include_start_time_state, significant_changes_only, minimal_response, @@ -270,7 +262,6 @@ async def _async_send_historical_states( start_time: dt, end_time: dt, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -286,7 +277,6 @@ async def _async_send_historical_states( start_time, end_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -365,8 +355,7 @@ def _async_subscribe_events( hass: HomeAssistant, subscriptions: list[CALLBACK_TYPE], target: Callable[[Event], None], - entities_filter: EntityFilter | None, - entity_ids: list[str] | None, + entity_ids: list[str], significant_changes_only: bool, minimal_response: bool, ) -> None: @@ -386,7 +375,7 @@ def _async_subscribe_events( return assert isinstance(new_state, State) assert isinstance(old_state, State) - if (entities_filter and not entities_filter(new_state.entity_id)) or ( + if ( (significant_changes_only or minimal_response) and new_state.state == old_state.state and new_state.domain not in history.SIGNIFICANT_DOMAINS @@ -394,21 +383,8 @@ def _async_subscribe_events( return target(event) - if entity_ids: - subscriptions.append( - async_track_state_change_event( - hass, entity_ids, _forward_state_events_filtered - ) - ) - return - - # We want the firehose subscriptions.append( - hass.bus.async_listen( - EVENT_STATE_CHANGED, - _forward_state_events_filtered, - run_immediately=True, - ) + async_track_state_change_event(hass, entity_ids, _forward_state_events_filtered) ) @@ -417,7 +393,7 @@ def _async_subscribe_events( vol.Required("type"): "history/stream", vol.Required("start_time"): str, vol.Optional("end_time"): str, - vol.Optional("entity_ids"): [str], + vol.Required("entity_ids"): [str], vol.Optional("include_start_time_state", default=True): bool, vol.Optional("significant_changes_only", default=True): bool, vol.Optional("minimal_response", default=False): bool, @@ -431,15 +407,7 @@ async def ws_stream( """Handle history stream websocket command.""" start_time_str = msg["start_time"] msg_id: int = msg["id"] - entity_ids: list[str] | None = msg.get("entity_ids") utc_now = dt_util.utcnow() - filters: Filters | None = None - entities_filter: EntityFilter | None = None - - if not entity_ids: - history_config: HistoryConfig = hass.data[DOMAIN] - filters = history_config.sqlalchemy_filter - entities_filter = history_config.entity_filter if start_time := dt_util.parse_datetime(start_time_str): start_time = dt_util.as_utc(start_time) @@ -459,7 +427,7 @@ async def ws_stream( connection.send_error(msg_id, "invalid_end_time", "Invalid end_time") return - entity_ids = msg.get("entity_ids") + entity_ids: list[str] = msg["entity_ids"] include_start_time_state = msg["include_start_time_state"] significant_changes_only = msg["significant_changes_only"] no_attributes = msg["no_attributes"] @@ -485,7 +453,6 @@ async def ws_stream( start_time, end_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -535,7 +502,6 @@ async def ws_stream( hass, subscriptions, _queue_or_cancel, - entities_filter, entity_ids, significant_changes_only=significant_changes_only, minimal_response=minimal_response, @@ -551,7 +517,6 @@ async def ws_stream( start_time, subscriptions_setup_complete_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -593,7 +558,6 @@ async def ws_stream( last_event_time or start_time, subscriptions_setup_complete_time, entity_ids, - filters, False, # We don't want the start time state again significant_changes_only, minimal_response, diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 85d8abd1cba..d0464968d4e 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -151,7 +151,7 @@ class DeviceWithPrograms(HomeConnectDevice): programs_available = self.appliance.get_programs_available() except (HomeConnectError, ValueError): _LOGGER.debug("Unable to fetch available programs. Probably offline") - programs_available = None + programs_available = [] return programs_available def get_program_switches(self): diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 91dd742e802..987a4317ba8 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -33,10 +33,12 @@ from homeassistant.helpers.service import ( from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType +from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .exposed_entities import ExposedEntities + ATTR_ENTRY_ID = "entry_id" _LOGGER = logging.getLogger(__name__) -DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" @@ -340,4 +342,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all ) + exposed_entities = ExposedEntities(hass) + await exposed_entities.async_initialize() + hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + return True diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py new file mode 100644 index 00000000000..f3bc95dd1ee --- /dev/null +++ b/homeassistant/components/homeassistant/const.py @@ -0,0 +1,6 @@ +"""Constants for the Homeassistant integration.""" +import homeassistant.core as ha + +DOMAIN = ha.DOMAIN + +DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites" diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py new file mode 100644 index 00000000000..9317f43ea75 --- /dev/null +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -0,0 +1,351 @@ +"""Control which entities are exposed to voice assistants.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import dataclasses +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.storage import Store + +from .const import DATA_EXPOSED_ENTITIES, DOMAIN + +KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant") + +STORAGE_KEY = f"{DOMAIN}.exposed_entities" +STORAGE_VERSION = 1 + +SAVE_DELAY = 10 + +DEFAULT_EXPOSED_DOMAINS = { + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "scene", + "script", + "switch", + "vacuum", + "water_heater", +} + +DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + + +@dataclasses.dataclass(frozen=True) +class AssistantPreferences: + """Preferences for an assistant.""" + + expose_new: bool + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return {"expose_new": self.expose_new} + + +class ExposedEntities: + """Control assistant settings.""" + + _assistants: dict[str, AssistantPreferences] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + self._listeners: dict[str, list[Callable[[], None]]] = {} + self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + + async def async_initialize(self) -> None: + """Finish initializing.""" + websocket_api.async_register_command(self._hass, ws_expose_entity) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) + await self.async_load() + + @callback + def async_listen_entity_updates( + self, assistant: str, listener: Callable[[], None] + ) -> None: + """Listen for updates to entity expose settings.""" + self._listeners.setdefault(assistant, []).append(listener) + + @callback + def async_expose_entity( + self, assistant: str, entity_id: str, should_expose: bool + ) -> None: + """Expose an entity to an assistant. + + Notify listeners if expose flag was changed. + """ + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError("Unknown entity") + + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(assistant, {}) + ) and assistant_options.get("should_expose") == should_expose: + return + + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + for listener in self._listeners.get(assistant, []): + listener() + + @callback + def async_get_expose_new_entities(self, assistant: str) -> bool: + """Check if new entities are exposed to an assistant.""" + if prefs := self._assistants.get(assistant): + return prefs.expose_new + return False + + @callback + def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None: + """Enable an assistant to expose new entities.""" + self._assistants[assistant] = AssistantPreferences(expose_new=expose_new) + self._async_schedule_save() + + @callback + def async_get_assistant_settings( + self, assistant: str + ) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + for entity_id, entry in entity_registry.entities.items(): + if options := entry.options.get(assistant): + result[entity_id] = options + + return result + + @callback + def async_should_expose(self, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + # Entities which are not in the entity registry are not exposed + return False + + if assistant in registry_entry.options: + if "should_expose" in registry_entry.options[assistant]: + should_expose = registry_entry.options[assistant]["should_expose"] + return should_expose + + if (prefs := self._assistants.get(assistant)) and prefs.expose_new: + should_expose = self._is_default_exposed(entity_id, registry_entry) + else: + should_expose = False + + assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + + return should_expose + + def _is_default_exposed( + self, entity_id: str, registry_entry: er.RegistryEntry + ) -> bool: + """Return True if an entity is exposed by default.""" + if ( + registry_entry.entity_category is not None + or registry_entry.hidden_by is not None + ): + return False + + domain = split_entity_id(entity_id)[0] + if domain in DEFAULT_EXPOSED_DOMAINS: + return True + + device_class = get_device_class(self._hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES: + return True + + return False + + async def async_load(self) -> None: + """Load from the store.""" + data = await self._store.async_load() + + assistants: dict[str, AssistantPreferences] = {} + + if data: + for domain, preferences in data["assistants"].items(): + assistants[domain] = AssistantPreferences(**preferences) + + self._assistants = assistants + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the preferences.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]: + """Return data to store in a file.""" + data = {} + + data["assistants"] = { + domain: preferences.to_json() + for domain, preferences in self._assistants.items() + } + + return data + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity", + vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)], + vol.Required("entity_ids"): [str], + vol.Required("should_expose"): bool, + } +) +def ws_expose_entity( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + entity_registry = er.async_get(hass) + entity_ids: str = msg["entity_ids"] + + if blocked := next( + ( + entity_id + for entity_id in entity_ids + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" + ) + return + + if unknown := next( + ( + entity_id + for entity_id in entity_ids + if entity_id not in entity_registry.entities + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'" + ) + return + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + for entity_id in entity_ids: + for assistant in msg["assistants"]: + exposed_entities.async_expose_entity( + assistant, entity_id, msg["should_expose"] + ) + connection.send_result(msg["id"]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/get", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + } +) +def ws_expose_new_entities_get( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Check if new entities are exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"]) + connection.send_result(msg["id"], {"expose_new": expose_new}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/set", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + vol.Required("expose_new"): bool, + } +) +def ws_expose_new_entities_set( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose new entities to an assistatant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) + connection.send_result(msg["id"]) + + +@callback +def async_listen_entity_updates( + hass: HomeAssistant, assistant: str, listener: Callable[[], None] +) -> None: + """Listen for updates to entity expose settings.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_listen_entity_updates(assistant, listener) + + +@callback +def async_get_assistant_settings( + hass: HomeAssistant, assistant: str +) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_assistant_settings(assistant) + + +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_should_expose(assistant, entity_id) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index d5a6202ea27..d3ad8c702fa 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -597,7 +597,9 @@ class HomeKit: await self._async_shutdown_accessory(acc) if new_acc := self._async_create_single_accessory([state]): self.driver.accessory = new_acc - self.hass.async_add_job(new_acc.run) + self.hass.async_create_task( + new_acc.run(), f"HomeKit Bridge Accessory: {new_acc.entity_id}" + ) await self.async_config_changed() async def async_reset_accessories_in_bridge_mode( @@ -637,7 +639,9 @@ class HomeKit: await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) for state in new: if acc := self.add_bridge_accessory(state): - self.hass.async_add_job(acc.run) + self.hass.async_create_task( + acc.run(), f"HomeKit Bridge Accessory: {acc.entity_id}" + ) await self.async_config_changed() async def async_config_changed(self) -> None: diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 4addbeb1e23..9c3d9e7929c 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -13,7 +13,7 @@ from __future__ import annotations from collections.abc import Generator import random -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 80eea60b9e8..def024d2046 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.6.0", - "fnvhash==0.1.0", + "fnv-hash-fast==0.3.1", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index d041f8e0551..74af388df85 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -24,7 +24,7 @@ "data": { "entities": "Entities" }, - "description": "All “{domains}” entities will be included unless specific entities are selected.", + "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.", "title": "Select the entities to be included" }, "exclude": { diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index b585f38e981..33c35908cd1 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -115,20 +115,12 @@ class HumidifierDehumidifier(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=0 ) - max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) - max_humidity = round(max_humidity) - max_humidity = min(max_humidity, 100) - - min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) - min_humidity = round(min_humidity) - min_humidity = max(min_humidity, 0) - self.char_target_humidity = serv_humidifier_dehumidifier.configure_char( self._target_humidity_char_name, value=45, properties={ - PROP_MIN_VALUE: min_humidity, - PROP_MAX_VALUE: max_humidity, + PROP_MIN_VALUE: DEFAULT_MIN_HUMIDITY, + PROP_MAX_VALUE: DEFAULT_MAX_HUMIDITY, PROP_MIN_STEP: 1, }, ) @@ -219,7 +211,23 @@ class HumidifierDehumidifier(HomeAccessory): ) if self._target_humidity_char_name in char_values: + state = self.hass.states.get(self.entity_id) + max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) + max_humidity = round(max_humidity) + max_humidity = min(max_humidity, 100) + + min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity = round(min_humidity) + min_humidity = max(min_humidity, 0) + humidity = round(char_values[self._target_humidity_char_name]) + + if (humidity < min_humidity) or (humidity > max_humidity): + humidity = min(max_humidity, max(min_humidity, humidity)) + # Update the HomeKit value to the clamped humidity, so the user will get a visual feedback that they + # cannot not set to a value below/above the min/max. + self.char_target_humidity.set_value(humidity) + self.async_call_service( DOMAIN, SERVICE_SET_HUMIDITY, diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a466d15db58..a741cf54920 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -27,8 +27,6 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity -ICON = "mdi:security" - CURRENT_STATE_MAP = { 0: STATE_ALARM_ARMED_HOME, 1: STATE_ALARM_ARMED_AWAY, @@ -72,6 +70,7 @@ async def async_setup_entry( class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -86,11 +85,6 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): CharacteristicsTypes.BATTERY_LEVEL, ] - @property - def icon(self) -> str: - """Return icon.""" - return ICON - @property def state(self) -> str: """Return the state of the device.""" diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 9e56c7c24ee..db85dbda3d5 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,7 +272,8 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), - f"HomeKit Controller {self.unique_id} BLE availability check poll", + name=f"HomeKit Controller {self.unique_id} BLE availability " + "check poll", ) ) # BLE devices always get an RSSI sensor as well @@ -290,7 +291,7 @@ class HKDevice: self.hass, self.async_request_update, self.pairing.poll_interval, - f"HomeKit Controller {self.unique_id} availability check poll", + name=f"HomeKit Controller {self.unique_id} availability check poll", ) ) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index e4eeea04f51..9db26d4c8e0 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.1"], + "requirements": ["aiohomekit==2.6.3"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index e9dae1e2074..bde70e6bb0c 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,9 +1,11 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" from __future__ import annotations +import asyncio import datetime from typing import Any +from aiohttp import ClientConnectionError import aiosomecomfort from homeassistant.components.climate import ( @@ -421,10 +423,7 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True - except ( - aiosomecomfort.SomeComfortError, - OSError, - ): + except aiosomecomfort.SomeComfortError: try: await self._data.client.login() @@ -433,5 +432,12 @@ class HoneywellUSThermostat(ClimateEntity): await self.hass.async_create_task( self.hass.config_entries.async_reload(self._data.entry_id) ) - except aiosomecomfort.SomeComfortError: + except ( + aiosomecomfort.SomeComfortError, + ClientConnectionError, + asyncio.TimeoutError, + ): self._attr_available = False + + except (ClientConnectionError, asyncio.TimeoutError): + self._attr_available = False diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 50f768915ed..452b23d27be 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -57,7 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ImageStorageCollection(collection.StorageCollection): +class ImageStorageCollection(collection.DictStorageCollection): """Image collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -67,7 +67,6 @@ class ImageStorageCollection(collection.StorageCollection): """Initialize media storage collection.""" super().__init__( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), ) self.async_add_listener(self._change_listener) self.image_dir = image_dir @@ -126,11 +125,11 @@ class ImageStorageCollection(collection.StorageCollection): async def _update_data( self, - data: dict[str, Any], + item: dict[str, Any], update_data: dict[str, Any], ) -> dict[str, Any]: """Return a new updated data object.""" - return {**data, **self.UPDATE_SCHEMA(update_data)} + return {**item, **self.UPDATE_SCHEMA(update_data)} async def _change_listener( self, diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 8dd3019878f..a6ef203283c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -9,7 +9,7 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv @@ -25,7 +25,7 @@ from .const import ( from .coordinator import connect_to_server from .errors import InvalidAuth, InvalidFolder -STEP_USER_DATA_SCHEMA = vol.Schema( +CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -77,14 +77,34 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: config_entries.ConfigEntry | None + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle the import from imap_email_content integration.""" + data = CONFIG_SCHEMA( + { + CONF_SERVER: user_input[CONF_SERVER], + CONF_PORT: user_input[CONF_PORT], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_FOLDER: user_input[CONF_FOLDER], + } + ) + self._async_abort_entries_match( + { + key: data[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) + } + ) + title = user_input[CONF_NAME] + if await validate_input(data): + raise AbortFlow("cannot_connect") + return self.async_create_entry(title=title, data=data) + 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 - ) + return self.async_show_form(step_id="user", data_schema=CONFIG_SCHEMA) self._async_abort_entries_match( { @@ -98,7 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) - schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + schema = self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 97432b91054..e11cf1e0baf 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -8,7 +8,7 @@ import email import logging from typing import Any -from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException +from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException import async_timeout from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -36,10 +36,12 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT]) await client.wait_hello_from_server() - await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) - if client.protocol.state != AUTH: + if client.protocol.state == NONAUTH: + await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) + if client.protocol.state not in {AUTH, SELECTED}: raise InvalidAuth("Invalid username or password") - await client.select(data[CONF_FOLDER]) + if client.protocol.state == AUTH: + await client.select(data[CONF_FOLDER]) if client.protocol.state != SELECTED: raise InvalidFolder(f"Folder {data[CONF_FOLDER]} is invalid") return client @@ -192,7 +194,11 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if count else None ) - if count and last_message_id is not None: + if ( + count + and last_message_id is not None + and self._last_message_id != last_message_id + ): self._last_message_id = last_message_id await self._async_process_event(last_message_id) @@ -207,10 +213,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): await self.imap_client.stop_wait_server_push() await self.imap_client.close() await self.imap_client.logout() - except (AioImapException, asyncio.TimeoutError) as ex: + except (AioImapException, asyncio.TimeoutError): if log_error: - self.async_set_update_error(ex) - _LOGGER.warning("Error while cleaning up imap connection") + _LOGGER.debug("Error while cleaning up imap connection") self.imap_client = None async def shutdown(self, *_) -> None: @@ -234,18 +239,18 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): UpdateFailed, asyncio.TimeoutError, ) as ex: - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) raise UpdateFailed() from ex except InvalidFolder as ex: _LOGGER.warning("Selected mailbox folder is invalid") - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) raise ConfigEntryError("Selected mailbox folder is invalid.") from ex except InvalidAuth as ex: _LOGGER.warning("Username or password incorrect, starting reauthentication") - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) raise ConfigEntryAuthFailed() from ex @@ -274,30 +279,30 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): try: number_of_messages = await self._async_fetch_number_of_messages() except InvalidAuth as ex: + await self._cleanup() _LOGGER.warning( "Username or password incorrect, starting reauthentication" ) self.config_entry.async_start_reauth(self.hass) self.async_set_update_error(ex) - await self._cleanup() await asyncio.sleep(BACKOFF_TIME) except InvalidFolder as ex: _LOGGER.warning("Selected mailbox folder is invalid") + await self._cleanup() self.config_entry.async_set_state( self.hass, ConfigEntryState.SETUP_ERROR, "Selected mailbox folder is invalid.", ) self.async_set_update_error(ex) - await self._cleanup() await asyncio.sleep(BACKOFF_TIME) except ( UpdateFailed, AioImapException, asyncio.TimeoutError, ) as ex: - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) await asyncio.sleep(BACKOFF_TIME) continue else: @@ -310,12 +315,11 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): await idle except (AioImapException, asyncio.TimeoutError): - _LOGGER.warning( + _LOGGER.debug( "Lost %s (will attempt to reconnect after %s s)", self.config_entry.data[CONF_SERVER], BACKOFF_TIME, ) - self.async_set_update_error(UpdateFailed("Lost connection")) await self._cleanup() await asyncio.sleep(BACKOFF_TIME) diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py index 263f57a3a9d..1a148f4591b 100644 --- a/homeassistant/components/imap_email_content/__init__.py +++ b/homeassistant/components/imap_email_content/__init__.py @@ -1 +1,12 @@ """The imap_email_content component.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up imap_email_content.""" + return True diff --git a/homeassistant/components/imap_email_content/const.py b/homeassistant/components/imap_email_content/const.py new file mode 100644 index 00000000000..5f1c653030e --- /dev/null +++ b/homeassistant/components/imap_email_content/const.py @@ -0,0 +1,13 @@ +"""Constants for the imap email content integration.""" + +DOMAIN = "imap_email_content" + +CONF_SERVER = "server" +CONF_SENDERS = "senders" +CONF_FOLDER = "folder" + +ATTR_FROM = "from" +ATTR_BODY = "body" +ATTR_SUBJECT = "subject" + +DEFAULT_PORT = 993 diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json index 2e510a8c426..b7d0589b83f 100644 --- a/homeassistant/components/imap_email_content/manifest.json +++ b/homeassistant/components/imap_email_content/manifest.json @@ -2,6 +2,7 @@ "domain": "imap_email_content", "name": "IMAP Email Content", "codeowners": [], + "dependencies": ["imap"], "documentation": "https://www.home-assistant.io/integrations/imap_email_content", "iot_class": "cloud_push" } diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py new file mode 100644 index 00000000000..f19b0499040 --- /dev/null +++ b/homeassistant/components/imap_email_content/repairs.py @@ -0,0 +1,173 @@ +"""Repair flow for imap email content integration.""" + +from typing import Any + +import voluptuous as vol +import yaml + +from homeassistant import data_entry_flow +from homeassistant.components.imap import DOMAIN as IMAP_DOMAIN +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_FOLDER, CONF_SENDERS, CONF_SERVER, DOMAIN + + +async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: + """Register an issue and suggest new config.""" + + name: str = config.get(CONF_NAME) or config[CONF_USERNAME] + + issue_id = ( + f"{name}_{config[CONF_USERNAME]}_{config[CONF_SERVER]}_{config[CONF_FOLDER]}" + ) + + if CONF_VALUE_TEMPLATE in config: + template: str = config[CONF_VALUE_TEMPLATE].template + template = template.replace("subject", 'trigger.event.data["subject"]') + template = template.replace("from", 'trigger.event.data["sender"]') + template = template.replace("date", 'trigger.event.data["date"]') + template = template.replace("body", 'trigger.event.data["text"]') + else: + template = '{{ trigger.event.data["subject"] }}' + + template_sensor_config: ConfigType = { + "template": [ + { + "trigger": [ + { + "id": "custom_event", + "platform": "event", + "event_type": "imap_content", + "event_data": {"sender": config[CONF_SENDERS][0]}, + } + ], + "sensor": [ + { + "state": template, + "name": name, + } + ], + } + ] + } + + data = { + CONF_SERVER: config[CONF_SERVER], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_FOLDER: config[CONF_FOLDER], + } + data[CONF_VALUE_TEMPLATE] = template + data[CONF_NAME] = name + placeholders = {"yaml_example": yaml.dump(template_sensor_config)} + placeholders.update(data) + + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="migration", + translation_placeholders=placeholders, + data=data, + ) + + +class DeprecationRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, issue_id: str, config: ConfigType) -> None: + """Create flow.""" + self._name: str = config[CONF_NAME] + self._config: dict[str, Any] = config + self._issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_start() + + @callback + def _async_get_placeholders(self) -> dict[str, str] | None: + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return description_placeholders + + async def async_step_start( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Wait for the user to start the config migration.""" + placeholders = self._async_get_placeholders() + if user_input is None: + return self.async_show_form( + step_id="start", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + placeholders = self._async_get_placeholders() + if user_input is not None: + user_input[CONF_NAME] = self._name + result = await self.hass.config_entries.flow.async_init( + IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config + ) + if result["type"] == FlowResultType.ABORT: + ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) + ir.async_create_issue( + self.hass, + DOMAIN, + self._issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecation", + translation_placeholders=placeholders, + data=self._config, + learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", + ) + return self.async_abort(reason=result["reason"]) + return self.async_create_entry( + title="", + data={}, + ) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None], +) -> RepairsFlow: + """Create flow.""" + return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index 53cb921860c..1df207e2968 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -26,18 +26,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.ssl import client_context +from .const import ( + ATTR_BODY, + ATTR_FROM, + ATTR_SUBJECT, + CONF_FOLDER, + CONF_SENDERS, + CONF_SERVER, + DEFAULT_PORT, +) +from .repairs import async_process_issue + _LOGGER = logging.getLogger(__name__) -CONF_SERVER = "server" -CONF_SENDERS = "senders" -CONF_FOLDER = "folder" - -ATTR_FROM = "from" -ATTR_BODY = "body" -ATTR_SUBJECT = "subject" - -DEFAULT_PORT = 993 - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, @@ -79,6 +80,8 @@ def setup_platform( value_template, ) + hass.add_job(async_process_issue, hass, config) + if sensor.connected: add_entities([sensor], True) diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json new file mode 100644 index 00000000000..f84435971bf --- /dev/null +++ b/homeassistant/components/imap_email_content/strings.json @@ -0,0 +1,27 @@ +{ + "issues": { + "deprecation": { + "title": "The IMAP email content integration is deprecated", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." + }, + "migration": { + "title": "The IMAP email content integration needs attention", + "fix_flow": { + "step": { + "start": { + "title": "Migrate your IMAP email configuration", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration can be migrated automatically to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap), this will enable using a custom `imap` event trigger. To set up a sensor that has an IMAP content state, a template sensor can be used. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml` after migration.\n\nSubmit to start migration of your IMAP server configuration to the `imap` integration." + }, + "confirm": { + "title": "Your IMAP server settings will be migrated", + "description": "In this step an `imap` config entry will be set up with the following configuration:\n\n```text\nServer\t{server}\nPort\t{port}\nUsername\t{username}\nPassword\t*****\nFolder\t{folder}\n```\n\nSee also: (https://www.home-assistant.io/integrations/imap/)\n\nFitering configuration on allowed `sender` is part of the template sensor config that can copied and placed in your `configuration.yaml.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\n```yaml\n{yaml_example}```\nDo not forget to cleanup the your `configuration.yaml` after migration.\n\nSubmit to migrate your IMAP server configuration to an `imap` configuration entry." + } + }, + "abort": { + "already_configured": "The IMAP server config was already migrated to the imap integration. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml`.", + "cannot_connect": "Migration failed. Failed to connect to the IMAP server. Perform a manual migration." + } + } + } + } +} diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a8b221e4939..49dcf731f7b 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -65,7 +65,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputBooleanStorageCollection(collection.StorageCollection): +class InputBooleanStorageCollection(collection.DictStorageCollection): """Input boolean collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -79,10 +79,10 @@ class InputBooleanStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data @bind_hass @@ -110,7 +110,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputBooleanStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index f8ff9164214..d9693a208c1 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -56,7 +56,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputButtonStorageCollection(collection.StorageCollection): +class InputButtonStorageCollection(collection.DictStorageCollection): """Input button collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -70,10 +70,10 @@ class InputButtonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputButtonStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 34ded40d583..c927b71c77e 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -148,7 +148,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = DateTimeStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -204,7 +203,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class DateTimeStorageCollection(collection.StorageCollection): +class DateTimeStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, has_date_or_time)) @@ -218,10 +217,10 @@ class DateTimeStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputDatetime(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 05d4a4f8b95..9f77bb0a828 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = NumberStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -171,7 +170,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class NumberStorageCollection(collection.StorageCollection): +class NumberStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_number)) @@ -185,7 +184,7 @@ class NumberStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused frontend to add initial value to all input numbers. @@ -201,10 +200,10 @@ class NumberStorageCollection(collection.StorageCollection): return data - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputNumber(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 9e4833954d6..b7a026352d0 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -156,7 +156,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: InputSelectStore( hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -232,7 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputSelectStorageCollection(collection.StorageCollection): +class InputSelectStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_select)) @@ -247,11 +246,11 @@ class InputSelectStorageCollection(collection.StorageCollection): return cast(str, info[CONF_NAME]) async def _update_data( - self, data: dict[str, Any], update_data: dict[str, Any] + self, item: dict[str, Any], update_data: dict[str, Any] ) -> dict[str, Any]: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 6ebfdcd70dc..f246779b64c 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputTextStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -165,7 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputTextStorageCollection(collection.StorageCollection): +class InputTextStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_text)) @@ -179,10 +178,10 @@ class InputTextStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputText(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index af9396399af..d9c2380de0f 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.4.0", + "pyinsteon==1.4.1", "insteon-frontend-home-assistant==0.3.4" ], "usb": [ diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 7ac30cc5a23..70b53b80d9c 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -30,7 +30,7 @@ CONF_DIRECTION = "direction" CONF_STOPS_AT = "stops_at" DEFAULT_NAME = "Next Train" -ICON = "mdi:train" + SCAN_INTERVAL = timedelta(minutes=2) TIME_STR_FORMAT = "%H:%M" @@ -76,6 +76,7 @@ class IrishRailTransportSensor(SensorEntity): """Implementation of an irish rail public transport sensor.""" _attr_attribution = "Data provided by Irish Rail" + _attr_icon = "mdi:train" def __init__(self, data, station, direction, destination, stops_at, name): """Initialize the sensor.""" @@ -128,11 +129,6 @@ class IrishRailTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and update the states.""" self.data.update() diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 37ae1a82b91..686ffdb72f3 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -335,8 +335,8 @@ UOM_FRIENDLY_NAME = { "18": UnitOfLength.FEET, "19": UnitOfTime.HOURS, "20": UnitOfTime.HOURS, - "21": "%AH", - "22": "%RH", + "21": PERCENTAGE, + "22": PERCENTAGE, "23": UnitOfPressure.INHG, "24": UnitOfVolumetricFlux.INCHES_PER_HOUR, UOM_INDEX: UOM_INDEX, # Index type. Use "node.formatted" for value diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index e5a45dbc5e6..4c9eb3a607c 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -142,8 +142,11 @@ class ControllerDevice(ClimateEntity): # If mode RAS, or mode master with CtrlZone 13 then can set master temperature, # otherwise the unit determines which zone to use as target. See interface manual p. 8 + # It appears some systems may have a different numbering system, so will trigger + # this if the control zone is > total zones. if ( - controller.ras_mode == "master" and controller.zone_ctrl == 13 + controller.ras_mode == "master" + and controller.zone_ctrl > controller.zones_total ) or controller.ras_mode == "RAS": self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0afb..18a0c2f8f72 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853e..b7c0e55745e 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efdf..0c26d2c7dd5 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff20..21cebe46f26 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7b..c33ec16d617 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 3a44267bd41..0279af2e610 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -67,7 +67,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await async_migrate_entries( hass, config_entry.entry_id, update_entity_unique_id ) - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 947ab2b2a8c..9669648b4c5 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -61,7 +61,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda res: getattr(res, "heat_usage_mwh", None), + value_fn=lambda res: res.heat_usage_mwh, ), HeatMeterSensorEntityDescription( key="volume_usage_m3", @@ -70,7 +70,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, - value_fn=lambda res: getattr(res, "volume_usage_m3", None), + value_fn=lambda res: res.volume_usage_m3, ), HeatMeterSensorEntityDescription( key="heat_usage_gj", @@ -79,7 +79,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda res: getattr(res, "heat_usage_gj", None), + value_fn=lambda res: res.heat_usage_gj, ), HeatMeterSensorEntityDescription( key="heat_previous_year_mwh", @@ -88,7 +88,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "heat_previous_year_mwh", None), + value_fn=lambda res: res.heat_previous_year_mwh, ), HeatMeterSensorEntityDescription( key="heat_previous_year_gj", @@ -97,7 +97,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), + value_fn=lambda res: res.heat_previous_year_gj, ), HeatMeterSensorEntityDescription( key="volume_previous_year_m3", @@ -106,28 +106,28 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "volume_previous_year_m3", None), + value_fn=lambda res: res.volume_previous_year_m3, ), HeatMeterSensorEntityDescription( key="ownership_number", name="Ownership number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "ownership_number", None), + value_fn=lambda res: res.ownership_number, ), HeatMeterSensorEntityDescription( key="error_number", name="Error number", icon="mdi:home-alert", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "error_number", None), + value_fn=lambda res: res.error_number, ), HeatMeterSensorEntityDescription( key="device_number", name="Device number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "device_number", None), + value_fn=lambda res: res.device_number, ), HeatMeterSensorEntityDescription( key="measurement_period_minutes", @@ -135,7 +135,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "measurement_period_minutes", None), + value_fn=lambda res: res.measurement_period_minutes, ), HeatMeterSensorEntityDescription( key="power_max_kw", @@ -143,7 +143,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "power_max_kw", None), + value_fn=lambda res: res.power_max_kw, ), HeatMeterSensorEntityDescription( key="power_max_previous_year_kw", @@ -151,7 +151,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "power_max_previous_year_kw", None), + value_fn=lambda res: res.power_max_previous_year_kw, ), HeatMeterSensorEntityDescription( key="flowrate_max_m3ph", @@ -159,7 +159,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flowrate_max_m3ph", None), + value_fn=lambda res: res.flowrate_max_m3ph, ), HeatMeterSensorEntityDescription( key="flowrate_max_previous_year_m3ph", @@ -167,7 +167,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flowrate_max_previous_year_m3ph", None), + value_fn=lambda res: res.flowrate_max_previous_year_m3ph, ), HeatMeterSensorEntityDescription( key="return_temperature_max_c", @@ -175,7 +175,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "return_temperature_max_c", None), + value_fn=lambda res: res.return_temperature_max_c, ), HeatMeterSensorEntityDescription( key="return_temperature_max_previous_year_c", @@ -183,9 +183,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr( - res, "return_temperature_max_previous_year_c", None - ), + value_fn=lambda res: res.return_temperature_max_previous_year_c, ), HeatMeterSensorEntityDescription( key="flow_temperature_max_c", @@ -193,7 +191,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_temperature_max_c", None), + value_fn=lambda res: res.flow_temperature_max_c, ), HeatMeterSensorEntityDescription( key="flow_temperature_max_previous_year_c", @@ -201,7 +199,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_temperature_max_previous_year_c", None), + value_fn=lambda res: res.flow_temperature_max_previous_year_c, ), HeatMeterSensorEntityDescription( key="operating_hours", @@ -209,7 +207,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "operating_hours", None), + value_fn=lambda res: res.operating_hours, ), HeatMeterSensorEntityDescription( key="flow_hours", @@ -217,7 +215,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_hours", None), + value_fn=lambda res: res.flow_hours, ), HeatMeterSensorEntityDescription( key="fault_hours", @@ -225,7 +223,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "fault_hours", None), + value_fn=lambda res: res.fault_hours, ), HeatMeterSensorEntityDescription( key="fault_hours_previous_year", @@ -233,21 +231,21 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "fault_hours_previous_year", None), + value_fn=lambda res: res.fault_hours_previous_year, ), HeatMeterSensorEntityDescription( key="yearly_set_day", name="Yearly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "yearly_set_day", None), + value_fn=lambda res: res.yearly_set_day, ), HeatMeterSensorEntityDescription( key="monthly_set_day", name="Monthly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "monthly_set_day", None), + value_fn=lambda res: res.monthly_set_day, ), HeatMeterSensorEntityDescription( key="meter_date_time", @@ -265,13 +263,13 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "measuring_range_m3ph", None), + value_fn=lambda res: res.measuring_range_m3ph, ), HeatMeterSensorEntityDescription( key="settings_and_firmware", name="Settings and firmware", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "settings_and_firmware", None), + value_fn=lambda res: res.settings_and_firmware, ), ) diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 497ccf817bc..70f4c22cade 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -26,7 +26,6 @@ STATE_NOT_SCROBBLING = "Not Scrobbling" CONF_USERS = "users" -ICON = "mdi:radio-fm" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -64,6 +63,7 @@ class LastfmSensor(SensorEntity): """A class for the Last.fm account.""" _attr_attribution = "Data provided by Last.fm" + _attr_icon = "mdi:radio-fm" def __init__(self, user, lastfm_api): """Initialize the sensor.""" @@ -127,8 +127,3 @@ class LastfmSensor(SensorEntity): def entity_picture(self): """Avatar of the user.""" return self._cover - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 1bdbc618fdf..f0c38cdfb11 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -210,7 +210,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_setup() try: await coordinator.async_config_entry_first_refresh() - await coordinator.sensor_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: connection.async_stop() raise diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 1632cac3d1f..110661b1c5c 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, HEV_CYCLE_STATE -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity from .util import lifx_features HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( @@ -32,29 +32,24 @@ async def async_setup_entry( if lifx_features(coordinator.device)["hev"]: async_add_entities( - [ - LIFXHevCycleBinarySensorEntity( - coordinator=coordinator.sensor_coordinator, - description=HEV_CYCLE_STATE_SENSOR, - ) - ] + [LIFXHevCycleBinarySensorEntity(coordinator, HEV_CYCLE_STATE_SENSOR)] ) -class LIFXHevCycleBinarySensorEntity(LIFXSensorEntity, BinarySensorEntity): +class LIFXHevCycleBinarySensorEntity(LIFXEntity, BinarySensorEntity): """LIFX HEV cycle state binary sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._async_update_attrs() @callback diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 636f90aaf3b..b5f5373b3e8 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, IDENTIFY, RESTART -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( key=RESTART, @@ -38,22 +38,21 @@ async def async_setup_entry( domain_data = hass.data[DOMAIN] coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] async_add_entities( - cls(coordinator.sensor_coordinator) - for cls in (LIFXRestartButton, LIFXIdentifyButton) + [LIFXRestartButton(coordinator), LIFXIdentifyButton(coordinator)] ) -class LIFXButton(LIFXSensorEntity, ButtonEntity): +class LIFXButton(LIFXEntity, ButtonEntity): """Base LIFX button.""" _attr_has_entity_name: bool = True _attr_should_poll: bool = False - def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: + def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: """Initialise a LIFX button.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator.parent.serial_number}_{self.entity_description.key}" + f"{coordinator.serial_number}_{self.entity_description.key}" ) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 038f93c1e88..293ef369806 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -79,7 +79,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): self.device: Light = connection.device self.lock = asyncio.Lock() self.active_effect = FirmwareEffect.OFF - self.sensor_coordinator = LIFXSensorUpdateCoordinator(hass, self, title) + self._update_rssi: bool = False + self._rssi: int = 0 + self.last_used_theme: str = "" super().__init__( hass, @@ -100,6 +102,24 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): self.device.retry_count = MESSAGE_RETRIES self.device.unregister_timeout = UNAVAILABLE_GRACE + @property + def rssi(self) -> int: + """Return stored RSSI value.""" + return self._rssi + + @property + def rssi_uom(self) -> str: + """Return the RSSI unit of measurement.""" + if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: + return SIGNAL_STRENGTH_DECIBELS + + return SIGNAL_STRENGTH_DECIBELS_MILLIWATT + + @property + def current_infrared_brightness(self) -> str | None: + """Return the current infrared brightness as a string.""" + return infrared_brightness_value_to_option(self.device.infrared_brightness) + @property def serial_number(self) -> str: """Return the internal mac address.""" @@ -187,6 +207,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): if self.device.mac_addr == TARGET_ANY: self.device.mac_addr = response.target_addr + if self._update_rssi is True: + await self.async_update_rssi() + # Update extended multizone devices if lifx_features(self.device)["extended_multizone"]: await self.async_get_extended_color_zones() @@ -196,6 +219,12 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): await self.async_get_color_zones() await self.async_get_multizone_effect() + if lifx_features(self.device)["hev"]: + await self.async_get_hev_cycle() + + if lifx_features(self.device)["infrared"]: + await async_execute_lifx(self.device.get_infrared) + async def async_get_color_zones(self) -> None: """Get updated color information for each zone.""" zone = 0 @@ -357,64 +386,6 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """Return the enum value of the currently active firmware effect.""" return self.active_effect.value - -class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): - """DataUpdateCoordinator to gather data for a specific lifx device.""" - - def __init__( - self, - hass: HomeAssistant, - parent: LIFXUpdateCoordinator, - title: str, - ) -> None: - """Initialize DataUpdateCoordinator.""" - self.parent: LIFXUpdateCoordinator = parent - self.device: Light = parent.device - self._update_rssi: bool = False - self._rssi: int = 0 - self.last_used_theme: str = "" - - super().__init__( - hass, - _LOGGER, - name=f"{title} Sensors ({self.device.ip_addr})", - update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL), - # Refresh immediately because the changes are not visible - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=0, immediate=True - ), - ) - - @property - def rssi(self) -> int: - """Return stored RSSI value.""" - return self._rssi - - @property - def rssi_uom(self) -> str: - """Return the RSSI unit of measurement.""" - if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: - return SIGNAL_STRENGTH_DECIBELS - - return SIGNAL_STRENGTH_DECIBELS_MILLIWATT - - @property - def current_infrared_brightness(self) -> str | None: - """Return the current infrared brightness as a string.""" - return infrared_brightness_value_to_option(self.device.infrared_brightness) - - async def _async_update_data(self) -> None: - """Fetch all device data from the api.""" - - if self._update_rssi is True: - await self.async_update_rssi() - - if lifx_features(self.device)["hev"]: - await self.async_get_hev_cycle() - - if lifx_features(self.device)["infrared"]: - await async_execute_lifx(self.device.get_infrared) - async def async_set_infrared_brightness(self, option: str) -> None: """Set infrared brightness.""" infrared_brightness = infrared_brightness_option_to_value(option) @@ -425,13 +396,13 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): bulb: Light = self.device if bulb.power_level: # just flash the bulb for three seconds - await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) return # Turn the bulb on first, flash for 3 seconds, then turn off - await self.parent.async_set_power(state=True, duration=1) - await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + await self.async_set_power(state=True, duration=1) + await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) await asyncio.sleep(LIFX_IDENTIFY_DELAY) - await self.parent.async_set_power(state=False, duration=1) + await self.async_set_power(state=False, duration=1) def async_enable_rssi_updates(self) -> Callable[[], None]: """Enable RSSI signal strength updates.""" @@ -471,4 +442,4 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): """Apply the selected theme to the device.""" self.last_used_theme = theme_name theme = ThemeLibrary().get_theme(theme_name) - await ThemePainter(self.hass.loop).paint(theme, [self.parent.device]) + await ThemePainter(self.hass.loop).paint(theme, [self.device]) diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index 63996d60027..a86bda53cfd 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator +from .coordinator import LIFXUpdateCoordinator class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): @@ -27,21 +27,3 @@ class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): sw_version=self.bulb.host_firmware_version, suggested_area=self.bulb.group, ) - - -class LIFXSensorEntity(CoordinatorEntity[LIFXSensorUpdateCoordinator]): - """Representation of a LIFX sensor entity with a sensor coordinator.""" - - def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: - """Initialise the sensor.""" - super().__init__(coordinator) - self.bulb = coordinator.parent.device - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.parent.serial_number)}, - connections={(dr.CONNECTION_NETWORK_MAC, coordinator.parent.mac_address)}, - manufacturer="LIFX", - name=coordinator.parent.label, - model=products.product_map.get(self.bulb.product, "LIFX Bulb"), - sw_version=self.bulb.host_firmware_version, - suggested_area=self.bulb.group, - ) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index eb62cb8016e..e22baebfa81 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -274,9 +274,7 @@ class LIFXLight(LIFXEntity, LightEntity): "This device does not support setting HEV cycle state" ) - await self.coordinator.sensor_coordinator.async_set_hev_cycle_state( - power, duration or 0 - ) + await self.coordinator.async_set_hev_cycle_state(power, duration or 0) await self.update_during_transition(duration or 0) async def set_power( diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index 1b58ea04686..9ad457e0270 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -15,8 +15,8 @@ from .const import ( INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP, ) -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity from .util import lifx_features THEME_NAMES = [theme_name.lower() for theme_name in ThemeLibrary().themes] @@ -42,39 +42,33 @@ async def async_setup_entry( """Set up LIFX from a config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities: list[LIFXSensorEntity] = [] + entities: list[LIFXEntity] = [] if lifx_features(coordinator.device)["infrared"]: entities.append( - LIFXInfraredBrightnessSelectEntity( - coordinator.sensor_coordinator, description=INFRARED_BRIGHTNESS_ENTITY - ) + LIFXInfraredBrightnessSelectEntity(coordinator, INFRARED_BRIGHTNESS_ENTITY) ) if lifx_features(coordinator.device)["multizone"] is True: - entities.append( - LIFXThemeSelectEntity( - coordinator.sensor_coordinator, description=THEME_ENTITY - ) - ) + entities.append(LIFXThemeSelectEntity(coordinator, THEME_ENTITY)) async_add_entities(entities) -class LIFXInfraredBrightnessSelectEntity(LIFXSensorEntity, SelectEntity): +class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): """LIFX Nightvision infrared brightness configuration entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription, ) -> None: """Initialise the IR brightness config entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_current_option = coordinator.current_infrared_brightness @callback @@ -93,21 +87,21 @@ class LIFXInfraredBrightnessSelectEntity(LIFXSensorEntity, SelectEntity): await self.coordinator.async_set_infrared_brightness(option) -class LIFXThemeSelectEntity(LIFXSensorEntity, SelectEntity): +class LIFXThemeSelectEntity(LIFXEntity, SelectEntity): """Theme entity for LIFX multizone devices.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription, ) -> None: """Initialise the theme selection entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_current_option = None @callback diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index da03b33f52a..654b5285756 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_RSSI, DOMAIN -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity SCAN_INTERVAL = timedelta(seconds=30) @@ -35,24 +35,24 @@ async def async_setup_entry( ) -> None: """Set up LIFX sensor from config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([LIFXRssiSensor(coordinator.sensor_coordinator, RSSI_SENSOR)]) + async_add_entities([LIFXRssiSensor(coordinator, RSSI_SENSOR)]) -class LIFXRssiSensor(LIFXSensorEntity, SensorEntity): +class LIFXRssiSensor(LIFXEntity, SensorEntity): """LIFX RSSI sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SensorEntityDescription, ) -> None: """Initialise the RSSI sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_native_unit_of_measurement = coordinator.rssi_uom @callback diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 3a0a219d943..952363650d6 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from aiolivisi.const import CAPABILITY_CONFIG + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -65,8 +67,6 @@ class LivisiClimate(LivisiEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _attr_target_temperature_high = MAX_TEMPERATURE - _attr_target_temperature_low = MIN_TEMPERATURE def __init__( self, @@ -83,6 +83,10 @@ class LivisiClimate(LivisiEntity, ClimateEntity): self._temperature_capability = self.capabilities["RoomTemperature"] self._humidity_capability = self.capabilities["RoomHumidity"] + config = device.get(CAPABILITY_CONFIG, {}).get("RoomSetpoint", {}) + self._attr_max_temp = config.get("maxTemperature", MAX_TEMPERATURE) + self._attr_min_temp = config.get("minTemperature", MIN_TEMPERATURE) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" response = await self.aio_livisi.async_vrcc_set_temperature( diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 4b6d9444fd8..6cfcaec61d0 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime, timedelta import logging from typing import Any @@ -186,14 +186,23 @@ def _parse_event(event: dict[str, Any]) -> Event: def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" + start: datetime | date + end: datetime | date + if isinstance(event.start, datetime) and isinstance(event.end, datetime): + start = dt_util.as_local(event.start) + end = dt_util.as_local(event.end) + if (end - start) <= timedelta(seconds=0): + end = start + timedelta(minutes=30) + else: + start = event.start + end = event.end + if (end - start) <= timedelta(days=0): + end = start + timedelta(days=1) + return CalendarEvent( summary=event.summary, - start=dt_util.as_local(event.start) - if isinstance(event.start, datetime) - else event.start, - end=dt_util.as_local(event.end) - if isinstance(event.end, datetime) - else event.end, + start=start, + end=end, description=event.description, uid=event.uid, rrule=event.rrule.as_rrule_str() if event.rrule else None, diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 5e371f29ab8..740d107d625 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -1,43 +1,5 @@ # Describes the format for available lock services -clear_usercode: - name: Clear usercode - description: Clear a usercode from lock. - fields: - node_id: - name: Node ID - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - name: Code slot - description: Code slot to clear code from. - selector: - number: - min: 1 - max: 255 - -get_usercode: - name: Get usercode - description: Retrieve a usercode from lock. - fields: - node_id: - name: Node ID - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - name: Code slot - description: Code slot to retrieve a code from. - selector: - number: - min: 1 - max: 255 - lock: name: Lock description: Lock all or specified locks. @@ -66,29 +28,6 @@ open: selector: text: -set_usercode: - name: Set usercode - description: Set a usercode to lock. - fields: - node_id: - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - description: Code slot to set the code. - selector: - number: - min: 1 - max: 255 - usercode: - description: Code to set. - required: true - example: 1234 - selector: - text: - unlock: name: Unlock description: Unlock all or specified locks. diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 32301e98358..7d0eec5eb6d 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -204,7 +204,7 @@ def _humanify( # Process rows for row in rows: - context_id = context_lookup.memorize(row) + context_id_bin = context_lookup.memorize(row) if row.context_only: continue event_type = row.event_type @@ -232,7 +232,7 @@ def _humanify( if icon := row.icon or row.old_format_icon: data[LOGBOOK_ENTRY_ICON] = icon - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type in external_events: @@ -240,7 +240,7 @@ def _humanify( data = describe_event(event_cache.get(row)) data[LOGBOOK_ENTRY_WHEN] = format_time(row) data[LOGBOOK_ENTRY_DOMAIN] = domain - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type == EVENT_LOGBOOK_ENTRY: @@ -259,7 +259,7 @@ def _humanify( LOGBOOK_ENTRY_DOMAIN: entry_domain, LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id, } - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data @@ -302,11 +302,11 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: bytes | None, row: Row | EventAsRow + self, context_id_bin: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" - if context_id: - return self.context_lookup.get(context_id) + if context_id_bin: + return self.context_lookup.get(context_id_bin) if (context := getattr(row, "context", None)) is not None and ( origin_event := context.origin_event ) is not None: @@ -314,13 +314,13 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id_bin: bytes | None ) -> None: """Augment data from the row and cache.""" if context_user_id_bin := row.context_user_id_bin: data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) - if not (context_row := self._get_context_row(context_id, row)): + if not (context_row := self._get_context_row(context_id_bin, row)): return if _rows_match(row, context_row): diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 2cad8e9a109..8217b3913a8 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -26,7 +26,6 @@ DOMAIN = "london_underground" CONF_LINE = "line" -ICON = "mdi:subway" SCAN_INTERVAL = timedelta(seconds=30) @@ -100,6 +99,7 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" _attr_attribution = "Powered by TfL Open Data" + _attr_icon = "mdi:subway" def __init__(self, coordinator, name): """Initialize the London Underground sensor.""" @@ -116,11 +116,6 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def extra_state_attributes(self): """Return other details about the sensor state.""" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index ef47ea0b1fc..054aaf9b24c 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -6,7 +6,6 @@ import logging import os from pathlib import Path import time -from typing import cast import voluptuous as vol @@ -218,7 +217,7 @@ def _config_info(mode, config): } -class DashboardsCollection(collection.StorageCollection): +class DashboardsCollection(collection.DictStorageCollection): """Collection of dashboards.""" CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) @@ -228,13 +227,12 @@ class DashboardsCollection(collection.StorageCollection): """Initialize the dashboards collection.""" super().__init__( storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), - _LOGGER, ) - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" if (data := await self.store.async_load()) is None: - return cast(dict | None, data) + return data updated = False @@ -246,7 +244,7 @@ class DashboardsCollection(collection.StorageCollection): if updated: await self.store.async_save(data) - return cast(dict | None, data) + return data async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" @@ -263,10 +261,10 @@ class DashboardsCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_URL_PATH] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - updated = {**data, **update_data} + updated = {**item, **update_data} if CONF_ICON in updated and updated[CONF_ICON] is None: updated.pop(CONF_ICON) diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index e6c4acfdf69..b6d0c939fec 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any import uuid import voluptuous as vol @@ -45,7 +45,7 @@ class ResourceYAMLCollection: return self.data -class ResourceStorageCollection(collection.StorageCollection): +class ResourceStorageCollection(collection.DictStorageCollection): """Collection to store resources.""" loaded = False @@ -56,7 +56,6 @@ class ResourceStorageCollection(collection.StorageCollection): """Initialize the storage collection.""" super().__init__( storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY), - _LOGGER, ) self.ll_config = ll_config @@ -68,10 +67,10 @@ class ResourceStorageCollection(collection.StorageCollection): return {"resources": len(self.async_items() or [])} - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" - if (data := await self.store.async_load()) is not None: - return cast(dict | None, data) + if (store_data := await self.store.async_load()) is not None: + return store_data # Import it from config. try: @@ -83,20 +82,20 @@ class ResourceStorageCollection(collection.StorageCollection): return None # Remove it from config and save both resources + config - data = conf[CONF_RESOURCES] + resources: list[dict[str, Any]] = conf[CONF_RESOURCES] try: - vol.Schema([RESOURCE_SCHEMA])(data) + vol.Schema([RESOURCE_SCHEMA])(resources) except vol.Invalid as err: _LOGGER.warning("Resource import failed. Data invalid: %s", err) return None conf.pop(CONF_RESOURCES) - for item in data: + for item in resources: item[CONF_ID] = uuid.uuid4().hex - data = {"items": data} + data: collection.SerializedStorageCollection = {"items": resources} await self.store.async_save(data) await self.ll_config.async_save(conf) @@ -114,7 +113,7 @@ class ResourceStorageCollection(collection.StorageCollection): """Return unique ID.""" return uuid.uuid4().hex - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" if not self.loaded: await self.async_load() @@ -124,4 +123,4 @@ class ResourceStorageCollection(collection.StorageCollection): if CONF_RESOURCE_TYPE_WS in update_data: update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) - return {**data, **update_data} + return {**item, **update_data} diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 67672759706..262a6701f56 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -32,42 +32,42 @@ from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure", - name="Pressure", + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.PA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure_at_sealevel", - name="Pressure at sealevel", + translation_key="pressure_at_sealevel", native_unit_of_measurement=UnitOfPressure.PA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="P1", - name="PM10", + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="P2", - name="PM2.5", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index 508e12924d3..d54bc6d0bdc 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -13,5 +13,25 @@ "invalid_sensor": "Sensor not available or invalid", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "pressure_at_sealevel": { "name": "Pressure at sealevel" }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + } + } } } diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d69..da77aea6c4a 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c413..fd6adb009aa 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -345,56 +346,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py new file mode 100644 index 00000000000..487594561d8 --- /dev/null +++ b/homeassistant/components/matter/cover.py @@ -0,0 +1,153 @@ +"""Matter cover.""" +from __future__ import annotations + +from enum import IntEnum +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import LOGGER +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +# The MASK used for extracting bits 0 to 1 of the byte. +OPERATIONAL_STATUS_MASK = 0b11 + + +class OperationalStatus(IntEnum): + """Currently ongoing operations enumeration for coverings, as defined in the Matter spec.""" + + COVERING_IS_CURRENTLY_NOT_MOVING = 0b00 + COVERING_IS_CURRENTLY_OPENING = 0b01 + COVERING_IS_CURRENTLY_CLOSING = 0b10 + RESERVED = 0b11 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Cover from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.COVER, async_add_entities) + + +class MatterCover(MatterEntity, CoverEntity): + """Representation of a Matter Cover.""" + + entity_description: CoverEntityDescription + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + if self._attr_current_cover_position: + current_position = self._attr_current_cover_position + else: + current_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage + ) + + assert current_position is not None + + return current_position + + @property + def is_closed(self) -> bool: + """Return true if cover is closed, else False.""" + return self.current_cover_position == 0 + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover movement.""" + await self.send_device_command(clusters.WindowCovering.Commands.StopMotion()) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.UpOrOpen()) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.DownOrClose()) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Set the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + await self.send_device_command( + clusters.WindowCovering.Commands.GoToLiftValue(position) + ) + + async def send_device_command(self, command: Any) -> None: + """Send device command.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + operational_status = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.OperationalStatus + ) + + assert operational_status is not None + + LOGGER.debug( + "Operational status %s for %s", + f"{operational_status:#010b}", + self.entity_id, + ) + + state = operational_status & OPERATIONAL_STATUS_MASK + match state: + case OperationalStatus.COVERING_IS_CURRENTLY_OPENING: + self._attr_is_opening = True + self._attr_is_closing = False + case OperationalStatus.COVERING_IS_CURRENTLY_CLOSING: + self._attr_is_opening = False + self._attr_is_closing = True + case _: + self._attr_is_opening = False + self._attr_is_closing = False + + self._attr_current_cover_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage + ) + LOGGER.debug( + "Current position: %s for %s", + self._attr_current_cover_position, + self.entity_id, + ) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCover"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.OperationalStatus, + ), + ), +] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 9df4484e00d..28f5b6b7f90 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -10,6 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo @@ -18,6 +19,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.COVER: COVER_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index f507cf8cf32..a6dcb23cc47 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -35,9 +35,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP -ATTRIBUTION = ( - "Weather forecast from met.no, delivered by the Norwegian Meteorological Institute." -) DEFAULT_NAME = "Met.no" @@ -74,6 +71,10 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_attribution = ( + "Weather forecast from met.no, delivered by the Norwegian " + "Meteorological Institute." + ) _attr_has_entity_name = True _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -173,11 +174,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index efe80cb9d17..1cab9c9099f 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -20,8 +20,6 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) -ATTRIBUTION = "Data provided by Met Éireann" - DEFAULT_NAME = "Met Éireann" DOMAIN = "met_eireann" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index c4d8763efa7..cce35731c72 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP +from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" + _attr_attribution = "Data provided by Met Éireann" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -125,11 +126,6 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self): """Return the forecast array.""" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 95972a95bbe..e1a530eef97 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -83,6 +83,7 @@ class MeteoFranceWeather( ): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -203,8 +204,3 @@ class MeteoFranceWeather( } ) return forecast_data - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 14b953663d0..11346ab18f9 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -38,6 +38,7 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR @@ -98,8 +99,3 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data["weather"].wind_bearing - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 24dc4b67cd9..7dbf7656d37 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import TemplateError, Unauthorized +from homeassistant.exceptions import ConfigEntryError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -45,11 +45,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config_integration import ( - CONFIG_SCHEMA_ENTRY, - DEFAULT_VALUES, - PLATFORM_CONFIG_SCHEMA_BASE, -) +from .config_integration import CONFIG_SCHEMA_ENTRY, PLATFORM_CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, ATTR_QOS, @@ -83,6 +79,7 @@ from .const import ( # noqa: F401 ) from .models import ( # noqa: F401 MqttCommandTemplate, + MqttData, MqttValueTemplate, PublishPayloadType, ReceiveMessage, @@ -102,8 +99,6 @@ _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" SERVICE_DUMP = "dump" -MANDATORY_DEFAULT_VALUES = (CONF_PORT, CONF_DISCOVERY_PREFIX) - ATTR_TOPIC_TEMPLATE = "topic_template" ATTR_PAYLOAD_TEMPLATE = "payload_template" @@ -193,50 +188,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Remove unknown keys from config entry data. - - Extra keys may have been added when importing MQTT yaml configuration. - """ - filtered_data = { - k: entry.data[k] for k in CONFIG_ENTRY_CONFIG_KEYS if k in entry.data - } - if entry.data.keys() != filtered_data.keys(): - _LOGGER.warning( - ( - "The following unsupported configuration options were removed from the " - "MQTT config entry: %s" - ), - entry.data.keys() - filtered_data.keys(), - ) - hass.config_entries.async_update_entry(entry, data=filtered_data) - - -async def _async_auto_mend_config( - hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] -) -> None: - """Mends config fetched from config entry and adds missing values. - - This mends incomplete migration from old version of HA Core. - """ - entry_updated = False - entry_config = {**entry.data} - for key in MANDATORY_DEFAULT_VALUES: - if key not in entry_config: - entry_config[key] = DEFAULT_VALUES[key] - entry_updated = True - - if entry_updated: - hass.config_entries.async_update_entry(entry, data=entry_config) - - -def _merge_extended_config(entry: ConfigEntry, conf: ConfigType) -> dict[str, Any]: - """Merge advanced options in configuration.yaml config with config entry.""" - # Add default values - conf = {**DEFAULT_VALUES, **conf} - return {**conf, **entry.data} - - async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle signals of config entry being updated. @@ -245,45 +196,29 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) -async def async_fetch_config( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, Any] | None: - """Fetch fresh MQTT yaml config from the hass config.""" - mqtt_data = get_mqtt_data(hass) - hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_data.config = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) - - # Remove unknown keys from config entry data - _filter_entry_config(hass, entry) - - # Add missing defaults to migrate older config entries - await _async_auto_mend_config(hass, entry, mqtt_data.config or {}) - # Bail out if broker setting is missing - if CONF_BROKER not in entry.data: - _LOGGER.error("MQTT broker is not configured, please configure it") - return None - - # If user doesn't have configuration.yaml config, generate default values - # for options not in config entry data - if (conf := mqtt_data.config) is None: - conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) - - # Merge advanced configuration values from configuration.yaml - conf = _merge_extended_config(entry, conf) - return conf - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - mqtt_data = get_mqtt_data(hass, True) + # validate entry config + try: + conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) + except vol.MultipleInvalid as ex: + raise ConfigEntryError( + f"The MQTT config entry is invalid, please correct it: {ex}" + ) from ex - # Fetch configuration and add missing defaults for basic options - if (conf := await async_fetch_config(hass, entry)) is None: - # Bail out - return False + # Fetch configuration and add default values + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_yaml = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + client = MQTT(hass, entry, conf) + if DOMAIN in hass.data: + mqtt_data = get_mqtt_data(hass) + mqtt_data.config = mqtt_yaml + mqtt_data.client = client + else: + hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) + client.start(mqtt_data) await async_create_certificate_temp_files(hass, dict(entry.data)) - mqtt_data.client = MQTT(hass, entry, conf) # Restore saved subscriptions if mqtt_data.subscriptions_to_restore: mqtt_data.client.async_restore_tracked_subscriptions( @@ -349,7 +284,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return - assert mqtt_data.client is not None and msg_topic is not None + assert msg_topic is not None await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) hass.services.async_register( @@ -585,7 +520,6 @@ def async_subscribe_connection_status( def is_connected(hass: HomeAssistant) -> bool: """Return if MQTT client is connected.""" mqtt_data = get_mqtt_data(hass) - assert mqtt_data.client is not None return mqtt_data.client.connected @@ -603,7 +537,6 @@ async def async_remove_config_entry_device( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload MQTT dump and publish service when the config entry is unloaded.""" mqtt_data = get_mqtt_data(hass) - assert mqtt_data.client is not None mqtt_client = mqtt_data.client # Unload publish and dump services. diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 5585a6cee5f..27b13a24b7c 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -69,6 +69,7 @@ from .const import ( from .models import ( AsyncMessageCallbackType, MessageCallbackType, + MqttData, PublishMessage, PublishPayloadType, ReceiveMessage, @@ -111,11 +112,11 @@ async def async_publish( encoding: str | None = DEFAULT_ENCODING, ) -> None: """Publish message to a MQTT topic.""" - mqtt_data = get_mqtt_data(hass, True) - if mqtt_data.client is None or not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot publish to topic '{topic}', MQTT is not enabled" ) + mqtt_data = get_mqtt_data(hass) outgoing_payload = payload if not isinstance(payload, bytes): if not encoding: @@ -161,11 +162,11 @@ async def async_subscribe( Call the return value to unsubscribe. """ - mqtt_data = get_mqtt_data(hass, True) - if mqtt_data.client is None or not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled" ) + mqtt_data = get_mqtt_data(hass) # Support for a deprecated callback type was removed with HA core 2023.3.0 # The signature validation code can be removed from HA core 2023.5.0 non_default = 0 @@ -377,19 +378,16 @@ class MQTT: _mqttc: mqtt.Client _last_subscribe: float + _mqtt_data: MqttData def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - conf: ConfigType, + self, hass: HomeAssistant, config_entry: ConfigEntry, conf: ConfigType ) -> None: """Initialize Home Assistant MQTT client.""" - self._mqtt_data = get_mqtt_data(hass) - self.hass = hass self.config_entry = config_entry self.conf = conf + self._simple_subscriptions: dict[str, list[Subscription]] = {} self._wildcard_subscriptions: list[Subscription] = [] self.connected = False @@ -415,8 +413,6 @@ class MQTT: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) - self.init_client() - async def async_stop_mqtt(_event: Event) -> None: """Stop MQTT component.""" await self.async_disconnect() @@ -425,6 +421,14 @@ class MQTT: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) ) + def start( + self, + mqtt_data: MqttData, + ) -> None: + """Start Home Assistant MQTT client.""" + self._mqtt_data = mqtt_data + self.init_client() + @property def subscriptions(self) -> list[Subscription]: """Return the tracked subscriptions.""" diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 47f8a7cf492..443fc070fa1 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -65,17 +65,6 @@ from .util import valid_birth_will, valid_publish_topic DEFAULT_TLS_PROTOCOL = "auto" -DEFAULT_VALUES = { - CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, - CONF_DISCOVERY: DEFAULT_DISCOVERY, - CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX, - CONF_PORT: DEFAULT_PORT, - CONF_PROTOCOL: DEFAULT_PROTOCOL, - CONF_TRANSPORT: DEFAULT_TRANSPORT, - CONF_WILL_MESSAGE: DEFAULT_WILL, - CONF_KEEPALIVE: DEFAULT_KEEPALIVE, -} - PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( { Platform.ALARM_CONTROL_PANEL.value: vol.All( @@ -169,9 +158,11 @@ CLIENT_KEY_AUTH_MSG = ( CONFIG_SCHEMA_ENTRY = vol.Schema( { vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All( + vol.Coerce(int), vol.Range(min=15) + ), + vol.Required(CONF_BROKER): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_CERTIFICATE): str, @@ -180,13 +171,17 @@ CONFIG_SCHEMA_ENTRY = vol.Schema( CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG ): str, vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), - vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, - vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, - vol.Optional(CONF_DISCOVERY): cv.boolean, + vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All( + cv.string, vol.In(SUPPORTED_PROTOCOLS) + ), + vol.Optional(CONF_WILL_MESSAGE, default=DEFAULT_WILL): valid_birth_will, + vol.Optional(CONF_BIRTH_MESSAGE, default=DEFAULT_BIRTH): valid_birth_will, + vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean, # discovery_prefix must be a valid publish topic because if no # state topic is specified, it will be created with the given prefix. - vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, + vol.Optional( + CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX + ): valid_publish_topic, vol.Optional(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.All( cv.string, vol.In([TRANSPORT_TCP, TRANSPORT_WEBSOCKETS]) ), @@ -195,32 +190,6 @@ CONFIG_SCHEMA_ENTRY = vol.Schema( } ) -CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( - { - vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile), - vol.Inclusive( - CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Inclusive( - CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), - vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, - vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, - vol.Optional(CONF_DISCOVERY): cv.boolean, - # discovery_prefix must be a valid publish topic because if no - # state topic is specified, it will be created with the given prefix. - vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, - } -) - DEPRECATED_CONFIG_KEYS = [ CONF_BIRTH_MESSAGE, CONF_BROKER, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497d..41fd353359e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ RELOADABLE_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a764b24b2e8..9349dd5458b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -218,7 +218,8 @@ async def async_start( # noqa: C901 discovery_hash = (component, discovery_id) if discovery_hash in mqtt_data.discovery_already_discovered or payload: - async def discovery_done(_: Any) -> None: + @callback + def discovery_done(_: Any) -> None: pending = mqtt_data.discovery_pending_discovered[discovery_hash][ "pending" ] diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index cecb4b88bcd..69001b837a2 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -562,7 +562,6 @@ class MqttAvailability(Entity): def available(self) -> bool: """Return if the device is available.""" mqtt_data = get_mqtt_data(self.hass) - assert mqtt_data.client is not None client = mqtt_data.client if not client.connected and not self.hass.is_stopping: return False @@ -833,8 +832,37 @@ class MqttDiscoveryUpdate(Entity): else: await self.async_remove(force_remove=True) - async def discovery_callback(payload: MQTTDiscoveryPayload) -> None: - """Handle discovery update.""" + async def _async_process_discovery_update( + payload: MQTTDiscoveryPayload, + discovery_update: Callable[ + [MQTTDiscoveryPayload], Coroutine[Any, Any, None] + ], + discovery_data: DiscoveryInfoType, + ) -> None: + """Process discovery update.""" + try: + await discovery_update(payload) + finally: + send_discovery_done(self.hass, discovery_data) + + async def _async_process_discovery_update_and_remove( + payload: MQTTDiscoveryPayload, discovery_data: DiscoveryInfoType + ) -> None: + """Process discovery update and remove entity.""" + self._cleanup_discovery_on_remove() + await _async_remove_state_and_registry_entry(self) + send_discovery_done(self.hass, discovery_data) + + @callback + def discovery_callback(payload: MQTTDiscoveryPayload) -> None: + """Handle discovery update. + + If the payload has changed we will create a task to + do the discovery update. + + As this callback can fire when nothing has changed, this + is a normal function to avoid task creation until it is needed. + """ _LOGGER.debug( "Got update for entity with hash: %s '%s'", discovery_hash, @@ -847,17 +875,20 @@ class MqttDiscoveryUpdate(Entity): if not payload: # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) - self._cleanup_discovery_on_remove() - await _async_remove_state_and_registry_entry(self) - send_discovery_done(self.hass, self._discovery_data) + self.hass.async_create_task( + _async_process_discovery_update_and_remove( + payload, self._discovery_data + ) + ) elif self._discovery_update: if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: # Non-empty, changed payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) - try: - await self._discovery_update(payload) - finally: - send_discovery_done(self.hass, self._discovery_data) + self.hass.async_create_task( + _async_process_discovery_update( + payload, self._discovery_update, self._discovery_data + ) + ) else: # Non-empty, unchanged payload: Ignore to avoid changing states _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 84735c55e08..eac333e2a7a 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -288,8 +288,8 @@ class EntityTopicState: class MqttData: """Keep the MQTT entry data.""" - client: MQTT | None = None - config: ConfigType | None = None + client: MQTT + config: ConfigType debug_info_entities: dict[str, EntityDebugInfo] = field(default_factory=dict) debug_info_triggers: dict[tuple[str, str], TriggerDebugInfo] = field( default_factory=dict diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 44deb0781cb..155756a58dc 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -136,12 +136,9 @@ def valid_birth_will(config: ConfigType) -> ConfigType: return config -def get_mqtt_data(hass: HomeAssistant, ensure_exists: bool = False) -> MqttData: +def get_mqtt_data(hass: HomeAssistant) -> MqttData: """Return typed MqttData from hass.data[DATA_MQTT].""" mqtt_data: MqttData - if ensure_exists: - mqtt_data = hass.data.setdefault(DATA_MQTT, MqttData()) - return mqtt_data mqtt_data = hass.data[DATA_MQTT] return mqtt_data diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 68f8bb566f1..213e268696e 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -172,7 +172,7 @@ class MySensorsLightRGB(MySensorsLight): new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) if new_rgb is None: return - hex_color = "%02x%02x%02x" % new_rgb + hex_color = "{:02x}{:02x}{:02x}".format(*new_rgb) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) @@ -219,7 +219,7 @@ class MySensorsLightRGBW(MySensorsLightRGB): new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) if new_rgbw is None: return - hex_color = "%02x%02x%02x%02x" % new_rgbw + hex_color = "{:02x}{:02x}{:02x}{:02x}".format(*new_rgbw) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 4c8a310ad11..a5521596208 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( key="restart", - name="Restart", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, ) diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index b78acbf3249..3f9821a1e34 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -89,7 +89,7 @@ class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysM SENSORS: tuple[NAMSensorEntityDescription, ...] = ( NAMSensorEntityDescription( key=ATTR_BME280_HUMIDITY, - name="BME280 humidity", + translation_key="bme280_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -98,7 +98,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BME280_PRESSURE, - name="BME280 pressure", + translation_key="bme280_pressure", suggested_display_precision=0, native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -107,7 +107,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BME280_TEMPERATURE, - name="BME280 temperature", + translation_key="bme280_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -116,7 +116,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP180_PRESSURE, - name="BMP180 pressure", + translation_key="bmp180_pressure", suggested_display_precision=0, native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -125,7 +125,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP180_TEMPERATURE, - name="BMP180 temperature", + translation_key="bmp180_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -134,7 +134,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP280_PRESSURE, - name="BMP280 pressure", + translation_key="bmp280_pressure", suggested_display_precision=0, native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -143,7 +143,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP280_TEMPERATURE, - name="BMP280 temperature", + translation_key="bmp280_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -152,7 +152,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_HECA_HUMIDITY, - name="HECA humidity", + translation_key="heca_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -161,7 +161,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_HECA_TEMPERATURE, - name="HECA temperature", + translation_key="heca_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -170,7 +170,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_MHZ14A_CARBON_DIOXIDE, - name="MH-Z14A carbon dioxide", + translation_key="mhz14a_carbon_dioxide", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -179,22 +179,21 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI, - name="PMSx003 CAQI", + translation_key="pmsx003_caqi", icon="mdi:air-filter", value=lambda sensors: sensors.pms_caqi, ), NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI_LEVEL, - name="PMSx003 CAQI level", + translation_key="pmsx003_caqi_level", icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], - translation_key="caqi_level", value=lambda sensors: sensors.pms_caqi_level, ), NAMSensorEntityDescription( key=ATTR_PMSX003_P0, - name="PMSx003 particulate matter 1.0", + translation_key="pmsx003_pm1", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, @@ -203,7 +202,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_PMSX003_P1, - name="PMSx003 particulate matter 10", + translation_key="pmsx003_pm10", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -212,7 +211,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_PMSX003_P2, - name="PMSx003 particulate matter 2.5", + translation_key="pmsx003_pm25", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, @@ -221,22 +220,21 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SDS011_CAQI, - name="SDS011 CAQI", + translation_key="sds011_caqi", icon="mdi:air-filter", value=lambda sensors: sensors.sds011_caqi, ), NAMSensorEntityDescription( key=ATTR_SDS011_CAQI_LEVEL, - name="SDS011 CAQI level", + translation_key="sds011_caqi_level", icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], - translation_key="caqi_level", value=lambda sensors: sensors.sds011_caqi_level, ), NAMSensorEntityDescription( key=ATTR_SDS011_P1, - name="SDS011 particulate matter 10", + translation_key="sds011_pm10", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -245,7 +243,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SDS011_P2, - name="SDS011 particulate matter 2.5", + translation_key="sds011_pm25", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, @@ -254,7 +252,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SHT3X_HUMIDITY, - name="SHT3X humidity", + translation_key="sht3x_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -263,7 +261,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SHT3X_TEMPERATURE, - name="SHT3X temperature", + translation_key="sht3x_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -272,22 +270,21 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_CAQI, - name="SPS30 CAQI", + translation_key="sps30_caqi", icon="mdi:air-filter", value=lambda sensors: sensors.sps30_caqi, ), NAMSensorEntityDescription( key=ATTR_SPS30_CAQI_LEVEL, - name="SPS30 CAQI level", + translation_key="sps30_caqi_level", icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], - translation_key="caqi_level", value=lambda sensors: sensors.sps30_caqi_level, ), NAMSensorEntityDescription( key=ATTR_SPS30_P0, - name="SPS30 particulate matter 1.0", + translation_key="sps30_pm1", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, @@ -296,7 +293,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_P1, - name="SPS30 particulate matter 10", + translation_key="sps30_pm10", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -305,7 +302,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_P2, - name="SPS30 particulate matter 2.5", + translation_key="sps30_pm25", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, @@ -314,7 +311,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_P4, - name="SPS30 particulate matter 4.0", + translation_key="sps30_pm4", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:molecule", @@ -323,7 +320,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_DHT22_HUMIDITY, - name="DHT22 humidity", + translation_key="dht22_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -332,7 +329,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_DHT22_TEMPERATURE, - name="DHT22 temperature", + translation_key="dht22_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -341,7 +338,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SIGNAL_STRENGTH, - name="Signal strength", + translation_key="signal_strength", suggested_display_precision=0, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -352,7 +349,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_UPTIME, - name="Uptime", + translation_key="last_restart", device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 17983505e91..682ca7756e0 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -39,8 +39,47 @@ } }, "entity": { + "button": { + "restart": { + "name": "[%key:component::button::entity_component::restart::name%]" + } + }, "sensor": { - "caqi_level": { + "bme280_humidity": { + "name": "BME280 humidity" + }, + "bme280_pressure": { + "name": "BME280 pressure" + }, + "bme280_temperature": { + "name": "BME280 temperature" + }, + "bmp180_pressure": { + "name": "BMP180 pressure" + }, + "bmp180_temperature": { + "name": "BMP180 temperature" + }, + "bmp280_pressure": { + "name": "BMP280 pressure" + }, + "bmp280_temperature": { + "name": "BMP280 temperature" + }, + "heca_humidity": { + "name": "HECA humidity" + }, + "heca_temperature": { + "name": "HECA temperature" + }, + "mhz14a_carbon_dioxide": { + "name": "MH-Z14A carbon dioxide" + }, + "pmsx003_caqi": { + "name": "PMSx003 common air quality index" + }, + "pmsx003_caqi_level": { + "name": "PMSx003 common air quality index level", "state": { "very_low": "Very low", "low": "Low", @@ -48,6 +87,77 @@ "high": "High", "very_high": "Very high" } + }, + "pmsx003_pm1": { + "name": "PMSx003 particulate matter 1 μm" + }, + "pmsx003_pm10": { + "name": "PMSx003 particulate matter 10 μm" + }, + "pmsx003_pm25": { + "name": "PMSx003 particulate matter 2.5 μm" + }, + "sds011_caqi": { + "name": "SDS011 common air quality index" + }, + "sds011_caqi_level": { + "name": "SDS011 common air quality index level", + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + }, + "sds011_pm10": { + "name": "SDS011 particulate matter 10 μm" + }, + "sds011_pm25": { + "name": "SDS011 particulate matter 2.5 μm" + }, + "sht3x_humidity": { + "name": "SHT3X humidity" + }, + "sht3x_temperature": { + "name": "SHT3X temperature" + }, + "sps30_caqi": { + "name": "SPS30 common air quality index" + }, + "sps30_caqi_level": { + "name": "SPS30 common air quality index level", + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + }, + "sps30_pm1": { + "name": "SPS30 particulate matter 1 μm" + }, + "sps30_pm10": { + "name": "SPS30 particulate matter 10 μm" + }, + "sps30_pm25": { + "name": "SPS30 particulate matter 2.5 μm" + }, + "sps30_pm4": { + "name": "SPS30 Particulate matter 4 μm" + }, + "dht22_humidity": { + "name": "DHT22 humidity" + }, + "dht22_temperature": { + "name": "DHT22 temperature" + }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "last_restart": { + "name": "Last restart" } } } diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 7f4fbdfae7f..f0c782bc1b5 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -26,7 +26,6 @@ CONF_TO = "to" CONF_VIA = "via" CONF_TIME = "time" -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -104,6 +103,7 @@ class NSDepartureSensor(SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_attribution = "Data provided by NS" + _attr_icon = "mdi:train" def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" @@ -121,11 +121,6 @@ class NSDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_value(self): """Return the next departure time.""" diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 187ac0ee8c2..8eb607b2056 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -79,7 +79,7 @@ class TemperatureSensor(SensorBase): _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_name = "Temperature" + _attr_translation_key = "temperature" @property def native_value(self) -> float: @@ -96,7 +96,7 @@ class HumiditySensor(SensorBase): _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE - _attr_name = "Humidity" + _attr_translation_key = "humidity" @property def native_value(self) -> int: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index bf68d1988d6..c0c7042423b 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -98,5 +98,15 @@ "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } + } } } diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 52f6d1d7225..a9023ffca2b 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -33,7 +33,6 @@ DAILY_NAME = "Daily Energy Usage" ACTIVE_TYPE = "active" DAILY_TYPE = "daily" -ICON = "mdi:flash" MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=150) MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=10) @@ -140,6 +139,8 @@ class NeurioData: class NeurioEnergy(SensorEntity): """Implementation of a Neurio energy sensor.""" + _attr_icon = "mdi:flash" + def __init__(self, data, name, sensor_type, update_call): """Initialize the sensor.""" self._name = name @@ -172,11 +173,6 @@ class NeurioEnergy(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data, update state.""" self.update_sensor() diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index bf45b9a4871..e2e37ccab2d 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -43,14 +43,14 @@ SENSORS = ( NextDnsBinarySensorEntityDescription[ConnectionStatus]( key="this_device_nextdns_connection_status", entity_category=EntityCategory.DIAGNOSTIC, - name="This device NextDNS connection status", + translation_key="device_connection_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, state=lambda data, _: data.connected, ), NextDnsBinarySensorEntityDescription[ConnectionStatus]( key="this_device_profile_connection_status", entity_category=EntityCategory.DIAGNOSTIC, - name="This device profile connection status", + translation_key="device_profile_connection_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, state=lambda data, profile_id: profile_id == data.profile_id, ), diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index e527e03ee8c..2eafe2b477e 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -15,7 +15,7 @@ PARALLEL_UPDATES = 1 CLEAR_LOGS_BUTTON = ButtonEntityDescription( key="clear_logs", - name="Clear logs", + translation_key="clear_logs", entity_category=EntityCategory.CONFIG, ) diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 8b0578f386b..ccbbb5e534e 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -60,7 +60,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries", + translation_key="all_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.all_queries, @@ -70,7 +70,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries blocked", + translation_key="blocked_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.blocked_queries, @@ -80,7 +80,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries relayed", + translation_key="relayed_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.relayed_queries, @@ -90,7 +90,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries blocked ratio", + translation_key="blocked_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.blocked_queries_ratio, @@ -101,7 +101,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-HTTPS queries", + translation_key="doh_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doh_queries, @@ -112,7 +112,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-HTTP/3 queries", + translation_key="doh3_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doh3_queries, @@ -123,7 +123,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-TLS queries", + translation_key="dot_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.dot_queries, @@ -134,7 +134,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-QUIC queries", + translation_key="doq_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doq_queries, @@ -145,7 +145,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="TCP queries", + translation_key="tcp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.tcp_queries, @@ -156,7 +156,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="UDP queries", + translation_key="udp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.udp_queries, @@ -167,7 +167,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, - name="DNS-over-HTTPS queries ratio", + translation_key="doh_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.doh_queries_ratio, @@ -178,7 +178,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, - name="DNS-over-HTTP/3 queries ratio", + translation_key="doh3_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.doh3_queries_ratio, @@ -189,7 +189,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-TLS queries ratio", + translation_key="dot_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.dot_queries_ratio, @@ -200,7 +200,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, - name="DNS-over-QUIC queries ratio", + translation_key="doq_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.doq_queries_ratio, @@ -211,7 +211,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="TCP queries ratio", + translation_key="tcp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.tcp_queries_ratio, @@ -222,7 +222,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="UDP queries ratio", + translation_key="udp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.udp_queries_ratio, @@ -233,7 +233,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock", - name="Encrypted queries", + translation_key="encrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.encrypted_queries, @@ -244,7 +244,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-open", - name="Unencrypted queries", + translation_key="unencrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.unencrypted_queries, @@ -255,7 +255,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock", - name="Encrypted queries ratio", + translation_key="encrypted_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.encrypted_queries_ratio, @@ -266,7 +266,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:ip", - name="IPv4 queries", + translation_key="ipv4_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.ipv4_queries, @@ -277,7 +277,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:ip", - name="IPv6 queries", + translation_key="ipv6_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.ipv6_queries, @@ -288,7 +288,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:ip", - name="IPv6 queries ratio", + translation_key="ipv6_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.ipv6_queries_ratio, @@ -299,7 +299,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-check", - name="DNSSEC validated queries", + translation_key="validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.validated_queries, @@ -310,7 +310,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-alert", - name="DNSSEC not validated queries", + translation_key="not_validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.not_validated_queries, @@ -321,7 +321,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-check", - name="DNSSEC validated queries ratio", + translation_key="validated_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.validated_queries_ratio, diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 59319881c02..517e229d0e4 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -25,5 +25,294 @@ "info": { "can_reach_server": "Reach server" } + }, + "entity": { + "binary_sensor": { + "device_connection_status": { + "name": "Device connection status" + }, + "device_profile_connection_status": { + "name": "Device profile connection status" + } + }, + "button": { + "clear_logs": { + "name": "Clear logs" + } + }, + "sensor": { + "all_queries": { + "name": "DNS queries" + }, + "blocked_queries": { + "name": "DNS queries blocked" + }, + "blocked_queries_ratio": { + "name": "DNS queries blocked ratio" + }, + "doh3_queries": { + "name": "DNS-over-HTTP/3 queries" + }, + "doh3_queries_ratio": { + "name": "DNS-over-HTTP/3 queries ratio" + }, + "doh_queries": { + "name": "DNS-over-HTTPS queries" + }, + "doh_queries_ratio": { + "name": "DNS-over-HTTPS queries ratio" + }, + "doq_queries": { + "name": "DNS-over-QUIC queries" + }, + "doq_queries_ratio": { + "name": "DNS-over-QUIC queries ratio" + }, + "dot_queries": { + "name": "DNS-over-TLS queries" + }, + "dot_queries_ratio": { + "name": "DNS-over-TLS queries ratio" + }, + "encrypted_queries": { + "name": "Encrypted queries" + }, + "encrypted_queries_ratio": { + "name": "Encrypted queries ratio" + }, + "ipv4_queries": { + "name": "IPv4 queries" + }, + "ipv6_queries": { + "name": "IPv6 queries" + }, + "ipv6_queries_ratio": { + "name": "IPv6 queries ratio" + }, + "not_validated_queries": { + "name": "DNSSEC not validated queries" + }, + "relayed_queries": { + "name": "DNS queries relayed" + }, + "tcp_queries": { + "name": "TCP queries" + }, + "tcp_queries_ratio": { + "name": "TCP queries ratio" + }, + "udp_queries": { + "name": "UDP queries" + }, + "udp_queries_ratio": { + "name": "UDP queries ratio" + }, + "unencrypted_queries": { + "name": "Unencrypted queries" + }, + "validated_queries": { + "name": "DNSSEC validated queries" + }, + "validated_queries_ratio": { + "name": "DNSSEC validated queries ratio" + } + }, + "switch": { + "ai_threat_detection": { + "name": "AI-Driven threat detection" + }, + "allow_affiliate": { + "name": "Allow affiliate & tracking links" + }, + "anonymized_ecs": { + "name": "Anonymized EDNS client subnet" + }, + "block_9gag": { + "name": "Block 9GAG" + }, + "block_amazon": { + "name": "Block Amazon" + }, + "block_blizzard": { + "name": "Block Blizzard" + }, + "block_bypass_methods": { + "name": "Block bypass methods" + }, + "block_csam": { + "name": "Block child sexual abuse material" + }, + "block_dailymotion": { + "name": "Block Dailymotion" + }, + "block_dating": { + "name": "Block dating" + }, + "block_ddns": { + "name": "Block dynamic DNS hostnames" + }, + "block_discord": { + "name": "Block Discord" + }, + "block_disguised_trackers": { + "name": "Block disguised third-party trackers" + }, + "block_disneyplus": { + "name": "Block Disney Plus" + }, + "block_ebay": { + "name": "Block eBay" + }, + "block_facebook": { + "name": "Block Facebook" + }, + "block_fortnite": { + "name": "Block Fortnite" + }, + "block_gambling": { + "name": "Block gambling" + }, + "block_hulu": { + "name": "Block Hulu" + }, + "block_imgur": { + "name": "Block Imgur" + }, + "block_instagram": { + "name": "Block Instagram" + }, + "block_leagueoflegends": { + "name": "Block League of Legends" + }, + "block_messenger": { + "name": "Block Messenger" + }, + "block_minecraft": { + "name": "Block Minecraft" + }, + "block_netflix": { + "name": "Block Netflix" + }, + "block_nrd": { + "name": "Block newly registered domains" + }, + "block_page": { + "name": "Block page" + }, + "block_parked_domains": { + "name": "Block parked domains" + }, + "block_pinterest": { + "name": "Block Pinterest" + }, + "block_piracy": { + "name": "Block piracy" + }, + "block_porn": { + "name": "Block porn" + }, + "block_primevideo": { + "name": "Block Prime Video" + }, + "block_reddit": { + "name": "Block Reddit" + }, + "block_roblox": { + "name": "Block Roblox" + }, + "block_signal": { + "name": "Block Signal" + }, + "block_skype": { + "name": "Block Skype" + }, + "block_snapchat": { + "name": "Block Snapchat" + }, + "block_social_networks": { + "name": "Block social networks" + }, + "block_spotify": { + "name": "Block Spotify" + }, + "block_steam": { + "name": "Block Steam" + }, + "block_telegram": { + "name": "Block Telegram" + }, + "block_tiktok": { + "name": "Block TikTok" + }, + "block_tinder": { + "name": "Block Tinder" + }, + "block_tumblr": { + "name": "Block Tumblr" + }, + "block_twitch": { + "name": "Block Twitch" + }, + "block_twitter": { + "name": "Block Twitter" + }, + "block_vimeo": { + "name": "Block Vimeo" + }, + "block_vk": { + "name": "Block VK" + }, + "block_whatsapp": { + "name": "Block WhatsApp" + }, + "block_xboxlive": { + "name": "Block Xbox Live" + }, + "block_youtube": { + "name": "Block YouTube" + }, + "block_zoom": { + "name": "Block Zoom" + }, + "cache_boost": { + "name": "Cache boost" + }, + "cname_flattening": { + "name": "CNAME flattening" + }, + "cryptojacking_protection": { + "name": "Cryptojacking protection" + }, + "dga_protection": { + "name": "Domain generation algorithms protection" + }, + "dns_rebinding_protection": { + "name": "DNS rebinding protection" + }, + "google_safe_browsing": { + "name": "Google safe browsing" + }, + "idn_homograph_attacks_protection": { + "name": "IDN homograph attacks protection" + }, + "logs": { + "name": "Logs" + }, + "safesearch": { + "name": "Force SafeSearch" + }, + "threat_intelligence_feeds": { + "name": "Threat intelligence feeds" + }, + "typosquatting_protection": { + "name": "Typosquatting protection" + }, + "web3": { + "name": "Web3" + }, + "youtube_restricted_mode": { + "name": "Force YouTube restricted mode" + } + } } } diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 703a30ad703..cd584b27713 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -41,156 +41,156 @@ class NextDnsSwitchEntityDescription( SWITCHES = ( NextDnsSwitchEntityDescription[Settings]( key="block_page", - name="Block page", + translation_key="block_page", entity_category=EntityCategory.CONFIG, icon="mdi:web-cancel", state=lambda data: data.block_page, ), NextDnsSwitchEntityDescription[Settings]( key="cache_boost", - name="Cache boost", + translation_key="cache_boost", entity_category=EntityCategory.CONFIG, icon="mdi:memory", state=lambda data: data.cache_boost, ), NextDnsSwitchEntityDescription[Settings]( key="cname_flattening", - name="CNAME flattening", + translation_key="cname_flattening", entity_category=EntityCategory.CONFIG, icon="mdi:tournament", state=lambda data: data.cname_flattening, ), NextDnsSwitchEntityDescription[Settings]( key="anonymized_ecs", - name="Anonymized EDNS client subnet", + translation_key="anonymized_ecs", entity_category=EntityCategory.CONFIG, icon="mdi:incognito", state=lambda data: data.anonymized_ecs, ), NextDnsSwitchEntityDescription[Settings]( key="logs", - name="Logs", + translation_key="logs", entity_category=EntityCategory.CONFIG, icon="mdi:file-document-outline", state=lambda data: data.logs, ), NextDnsSwitchEntityDescription[Settings]( key="web3", - name="Web3", + translation_key="web3", entity_category=EntityCategory.CONFIG, icon="mdi:web", state=lambda data: data.web3, ), NextDnsSwitchEntityDescription[Settings]( key="allow_affiliate", - name="Allow affiliate & tracking links", + translation_key="allow_affiliate", entity_category=EntityCategory.CONFIG, state=lambda data: data.allow_affiliate, ), NextDnsSwitchEntityDescription[Settings]( key="block_disguised_trackers", - name="Block disguised third-party trackers", + translation_key="block_disguised_trackers", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_disguised_trackers, ), NextDnsSwitchEntityDescription[Settings]( key="ai_threat_detection", - name="AI-Driven threat detection", + translation_key="ai_threat_detection", entity_category=EntityCategory.CONFIG, state=lambda data: data.ai_threat_detection, ), NextDnsSwitchEntityDescription[Settings]( key="block_csam", - name="Block child sexual abuse material", + translation_key="block_csam", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_csam, ), NextDnsSwitchEntityDescription[Settings]( key="block_ddns", - name="Block dynamic DNS hostnames", + translation_key="block_ddns", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_ddns, ), NextDnsSwitchEntityDescription[Settings]( key="block_nrd", - name="Block newly registered domains", + translation_key="block_nrd", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_nrd, ), NextDnsSwitchEntityDescription[Settings]( key="block_parked_domains", - name="Block parked domains", + translation_key="block_parked_domains", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_parked_domains, ), NextDnsSwitchEntityDescription[Settings]( key="cryptojacking_protection", - name="Cryptojacking protection", + translation_key="cryptojacking_protection", entity_category=EntityCategory.CONFIG, state=lambda data: data.cryptojacking_protection, ), NextDnsSwitchEntityDescription[Settings]( key="dga_protection", - name="Domain generation algorithms protection", + translation_key="dga_protection", entity_category=EntityCategory.CONFIG, state=lambda data: data.dga_protection, ), NextDnsSwitchEntityDescription[Settings]( key="dns_rebinding_protection", - name="DNS rebinding protection", + translation_key="dns_rebinding_protection", entity_category=EntityCategory.CONFIG, icon="mdi:dns", state=lambda data: data.dns_rebinding_protection, ), NextDnsSwitchEntityDescription[Settings]( key="google_safe_browsing", - name="Google safe browsing", + translation_key="google_safe_browsing", entity_category=EntityCategory.CONFIG, icon="mdi:google", state=lambda data: data.google_safe_browsing, ), NextDnsSwitchEntityDescription[Settings]( key="idn_homograph_attacks_protection", - name="IDN homograph attacks protection", + translation_key="idn_homograph_attacks_protection", entity_category=EntityCategory.CONFIG, state=lambda data: data.idn_homograph_attacks_protection, ), NextDnsSwitchEntityDescription[Settings]( key="threat_intelligence_feeds", - name="Threat intelligence feeds", + translation_key="threat_intelligence_feeds", entity_category=EntityCategory.CONFIG, state=lambda data: data.threat_intelligence_feeds, ), NextDnsSwitchEntityDescription[Settings]( key="typosquatting_protection", - name="Typosquatting protection", + translation_key="typosquatting_protection", entity_category=EntityCategory.CONFIG, icon="mdi:keyboard-outline", state=lambda data: data.typosquatting_protection, ), NextDnsSwitchEntityDescription[Settings]( key="block_bypass_methods", - name="Block bypass methods", + translation_key="block_bypass_methods", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_bypass_methods, ), NextDnsSwitchEntityDescription[Settings]( key="safesearch", - name="Force SafeSearch", + translation_key="safesearch", entity_category=EntityCategory.CONFIG, icon="mdi:search-web", state=lambda data: data.safesearch, ), NextDnsSwitchEntityDescription[Settings]( key="youtube_restricted_mode", - name="Force YouTube restricted mode", + translation_key="youtube_restricted_mode", entity_category=EntityCategory.CONFIG, icon="mdi:youtube", state=lambda data: data.youtube_restricted_mode, ), NextDnsSwitchEntityDescription[Settings]( key="block_9gag", - name="Block 9GAG", + translation_key="block_9gag", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:file-gif-box", @@ -198,7 +198,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_amazon", - name="Block Amazon", + translation_key="block_amazon", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:cart-outline", @@ -206,7 +206,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_blizzard", - name="Block Blizzard", + translation_key="block_blizzard", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:sword-cross", @@ -214,7 +214,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_dailymotion", - name="Block Dailymotion", + translation_key="block_dailymotion", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:movie-search-outline", @@ -222,7 +222,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_discord", - name="Block Discord", + translation_key="block_discord", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:message-text", @@ -230,7 +230,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_disneyplus", - name="Block Disney Plus", + translation_key="block_disneyplus", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:movie-search-outline", @@ -238,7 +238,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_ebay", - name="Block eBay", + translation_key="block_ebay", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:basket-outline", @@ -246,7 +246,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_facebook", - name="Block Facebook", + translation_key="block_facebook", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:facebook", @@ -254,7 +254,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_fortnite", - name="Block Fortnite", + translation_key="block_fortnite", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:tank", @@ -270,7 +270,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_imgur", - name="Block Imgur", + translation_key="block_imgur", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:camera-image", @@ -278,7 +278,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_instagram", - name="Block Instagram", + translation_key="block_instagram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:instagram", @@ -286,7 +286,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_leagueoflegends", - name="Block League of Legends", + translation_key="block_leagueoflegends", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:sword", @@ -294,7 +294,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_messenger", - name="Block Messenger", + translation_key="block_messenger", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:message-text", @@ -302,7 +302,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_minecraft", - name="Block Minecraft", + translation_key="block_minecraft", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:minecraft", @@ -310,7 +310,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_netflix", - name="Block Netflix", + translation_key="block_netflix", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:netflix", @@ -318,7 +318,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_pinterest", - name="Block Pinterest", + translation_key="block_pinterest", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:pinterest", @@ -326,7 +326,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_primevideo", - name="Block Prime Video", + translation_key="block_primevideo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:filmstrip", @@ -334,7 +334,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_reddit", - name="Block Reddit", + translation_key="block_reddit", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:reddit", @@ -342,7 +342,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_roblox", - name="Block Roblox", + translation_key="block_roblox", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:robot", @@ -350,7 +350,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_signal", - name="Block Signal", + translation_key="block_signal", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:chat-outline", @@ -358,7 +358,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_skype", - name="Block Skype", + translation_key="block_skype", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:skype", @@ -366,7 +366,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_snapchat", - name="Block Snapchat", + translation_key="block_snapchat", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:snapchat", @@ -374,7 +374,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_spotify", - name="Block Spotify", + translation_key="block_spotify", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:spotify", @@ -382,7 +382,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_steam", - name="Block Steam", + translation_key="block_steam", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:steam", @@ -390,7 +390,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_telegram", - name="Block Telegram", + translation_key="block_telegram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:send-outline", @@ -398,7 +398,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_tiktok", - name="Block TikTok", + translation_key="block_tiktok", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:music-note", @@ -406,7 +406,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_tinder", - name="Block Tinder", + translation_key="block_tinder", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:fire", @@ -414,7 +414,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_tumblr", - name="Block Tumblr", + translation_key="block_tumblr", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:image-outline", @@ -422,7 +422,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_twitch", - name="Block Twitch", + translation_key="block_twitch", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:twitch", @@ -430,7 +430,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_twitter", - name="Block Twitter", + translation_key="block_twitter", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:twitter", @@ -438,7 +438,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_vimeo", - name="Block Vimeo", + translation_key="block_vimeo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:vimeo", @@ -446,7 +446,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_vk", - name="Block VK", + translation_key="block_vk", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:power-socket-eu", @@ -454,7 +454,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_whatsapp", - name="Block WhatsApp", + translation_key="block_whatsapp", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:whatsapp", @@ -462,7 +462,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_xboxlive", - name="Block Xbox Live", + translation_key="block_xboxlive", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:microsoft-xbox", @@ -470,7 +470,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_youtube", - name="Block YouTube", + translation_key="block_youtube", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:youtube", @@ -478,7 +478,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_zoom", - name="Block Zoom", + translation_key="block_zoom", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:video", @@ -486,7 +486,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_dating", - name="Block dating", + translation_key="block_dating", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:candelabra", @@ -494,7 +494,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_gambling", - name="Block gambling", + translation_key="block_gambling", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:slot-machine", @@ -502,7 +502,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_piracy", - name="Block piracy", + translation_key="block_piracy", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:pirate", @@ -510,7 +510,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_porn", - name="Block porn", + translation_key="block_porn", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:movie-off", @@ -518,7 +518,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_social_networks", - name="Block social networks", + translation_key="block_social_networks", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:facebook", diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 5c3f9c59460..3745c6bae6f 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -39,7 +39,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_AREA = "area" ATTR_POLLUTION_INDEX = "nilu_pollution_index" -ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no" CONF_AREA = "area" CONF_STATION = "stations" @@ -173,6 +172,8 @@ class NiluData: class NiluSensor(AirQualityEntity): """Single nilu station air sensor.""" + _attr_attribution = "Data provided by luftkvalitet.info and nilu.no" + def __init__(self, api_data: NiluData, name: str, show_on_map: bool) -> None: """Initialize the sensor.""" self._api = api_data @@ -184,11 +185,6 @@ class NiluSensor(AirQualityEntity): self._attrs[CONF_LATITUDE] = api_data.data.latitude self._attrs[CONF_LONGITUDE] = api_data.data.longitude - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 8fb227140a1..7fe40af3b69 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -22,6 +22,8 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) +API_FAILURE = -1 + DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" @@ -162,16 +164,19 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if ( - liveboard is None - or liveboard.get("departures") is None - or liveboard.get("departures").get("number") is None - or liveboard.get("departures").get("number") == "0" - or liveboard.get("departures").get("departure") is None - ): + if liveboard == API_FAILURE: + _LOGGER.warning("API failed in NMBSLiveBoard") return - next_departure = liveboard["departures"]["departure"][0] + if not (departures := liveboard.get("departures")): + _LOGGER.warning("API returned invalid departures: %r", liveboard) + return + + _LOGGER.debug("API returned departures: %r", departures) + if departures["number"] == "0": + # No trains are scheduled + return + next_departure = departures["departure"][0] self._attrs = next_departure self._state = ( @@ -290,13 +295,19 @@ class NMBSSensor(SensorEntity): self._station_from, self._station_to ) - if connections is None or not connections.get("connection"): + if connections == API_FAILURE: + _LOGGER.warning("API failed in NMBSSensor") return - if int(connections["connection"][0]["departure"]["left"]) > 0: - next_connection = connections["connection"][1] + if not (connection := connections.get("connection")): + _LOGGER.warning("API returned invalid connection: %r", connections) + return + + _LOGGER.debug("API returned connection: %r", connection) + if int(connection[0]["departure"]["left"]) > 0: + next_connection = connection[1] else: - next_connection = connections["connection"][0] + next_connection = connection[0] self._attrs = next_connection diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index b4acdc3bdc9..1a3d3661a15 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -17,12 +17,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = ( - "Air quality from " - "https://luftkvalitet.miljostatus.no/, " - "delivered by the Norwegian Meteorological Institute." -) -# https://api.met.no/license_data.html CONF_FORECAST = "forecast" @@ -81,6 +75,13 @@ def round_state(func): class AirSensor(AirQualityEntity): """Representation of an air quality sensor.""" + # https://api.met.no/license_data.html + _attr_attribution = ( + "Air quality from " + "https://luftkvalitet.miljostatus.no/, " + "delivered by the Norwegian Meteorological Institute." + ) + def __init__(self, name, coordinates, forecast, session): """Initialize the sensor.""" self._name = name @@ -88,11 +89,6 @@ class AirSensor(AirQualityEntity): coordinates, forecast, session, api_url=OVERRIDE_URL ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 74245d30d4a..ef168374bd8 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -25,8 +25,13 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.network import get_url +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -146,6 +151,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True ) + webhook_url = webhook.async_generate_path(entry.entry_id) + + try: + hass_url = get_url( + hass, + allow_cloud=False, + allow_external=False, + allow_ip=True, + require_ssl=False, + ) + except NoURLAvailableError: + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady( + f"Error registering URL for webhook {entry.entry_id}: " + "HomeAssistant URL is not available" + ) from None + + url = f"{hass_url}{webhook_url}" + + if hass_url.startswith("https"): + ir.async_create_issue( + hass, + DOMAIN, + "https_webhook", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="https_webhook", + translation_placeholders={ + "base_url": hass_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(hass, DOMAIN, "https_webhook") + + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _register_webhook, bridge, entry.entry_id, url + ) + except InvalidCredentialsException as err: + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady( + f"Error communicating with Bridge: {err}" + ) from err + async def _stop_nuki(_: Event): """Stop and remove the Nuki webhook.""" webhook.async_unregister(hass, entry.entry_id) @@ -155,29 +209,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _remove_webhook, bridge, entry.entry_id ) except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + _LOGGER.error("Error communicating with bridge: %s", err) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) ) - webhook_url = webhook.async_generate_path(entry.entry_id) - hass_url = get_url( - hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False - ) - url = f"{hass_url}{webhook_url}" - try: - async with async_timeout.timeout(10): - await hass.async_add_executor_job( - _register_webhook, bridge, entry.entry_id, url - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - coordinator = NukiCoordinator(hass, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = { diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 93bf164acc5..2b3006eeb3b 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -36,7 +36,6 @@ class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): """Representation of a Nuki Lock Doorsensor.""" _attr_has_entity_name = True - _attr_name = "Door sensor" _attr_device_class = BinarySensorDeviceClass.DOOR @property diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 56b19b75a69..55560d3bf8c 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -71,6 +71,7 @@ class NukiDeviceEntity(NukiEntity[_NukiDeviceT], LockEntity): _attr_has_entity_name = True _attr_supported_features = LockEntityFeature.OPEN + _attr_translation_key = "nuki_lock" @property def unique_id(self) -> str | None: diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 55f74f5d8ad..c4578c7d14d 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -29,7 +29,7 @@ class NukiBatterySensor(NukiEntity[NukiDevice], SensorEntity): """Representation of a Nuki Lock Battery sensor.""" _attr_has_entity_name = True - _attr_name = "Battery" + _attr_translation_key = "battery" _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index 32b72c74252..f139124e961 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -25,5 +25,30 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "issues": { + "https_webhook": { + "title": "Nuki webhook URL uses HTTPS (SSL)", + "description": "The Nuki bridge can not push events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" + } + }, + "entity": { + "lock": { + "nuki_lock": { + "state_attributes": { + "battery_critical": { + "state": { + "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]", + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]" + } + } + } + } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + } + } } } diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 4ac28e07611..44adb78e6a0 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -23,8 +23,6 @@ from . import ( _LOGGER = logging.getLogger(__name__) -ICON = "mdi:gauge" - def setup_platform( hass: HomeAssistant, @@ -71,6 +69,8 @@ def setup_platform( class NumatoGpioAdc(SensorEntity): """Represents an ADC port of a Numato USB GPIO expander.""" + _attr_icon = "mdi:gauge" + def __init__(self, name, device_id, port, src_range, dst_range, dst_unit, api): """Initialize the sensor.""" self._name = name @@ -97,11 +97,6 @@ class NumatoGpioAdc(SensorEntity): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" try: diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 72ca53fc6b8..6574577558e 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -57,22 +57,22 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", - name="Status", + translation_key="ups_status_display", icon="mdi:information-outline", ), "ups.status": SensorEntityDescription( key="ups.status", - name="Status Data", + translation_key="ups_status", icon="mdi:information-outline", ), "ups.alarm": SensorEntityDescription( key="ups.alarm", - name="Alarms", + translation_key="ups_alarm", icon="mdi:alarm", ), "ups.temperature": SensorEntityDescription( key="ups.temperature", - name="UPS Temperature", + translation_key="ups_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -81,14 +81,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.load": SensorEntityDescription( key="ups.load", - name="Load", + translation_key="ups_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), "ups.load.high": SensorEntityDescription( key="ups.load.high", - name="Overload Setting", + translation_key="ups_load_high", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -96,14 +96,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.id": SensorEntityDescription( key="ups.id", - name="System identifier", + translation_key="ups_id", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.delay.start": SensorEntityDescription( key="ups.delay.start", - name="Load Restart Delay", + translation_key="ups_delay_start", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -111,7 +111,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.delay.reboot": SensorEntityDescription( key="ups.delay.reboot", - name="UPS Reboot Delay", + translation_key="ups_delay_reboot", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -119,7 +119,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.delay.shutdown": SensorEntityDescription( key="ups.delay.shutdown", - name="UPS Shutdown Delay", + translation_key="ups_delay_shutdown", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -127,7 +127,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.timer.start": SensorEntityDescription( key="ups.timer.start", - name="Load Start Timer", + translation_key="ups_timer_start", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -135,7 +135,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.timer.reboot": SensorEntityDescription( key="ups.timer.reboot", - name="Load Reboot Timer", + translation_key="ups_timer_reboot", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -143,7 +143,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.timer.shutdown": SensorEntityDescription( key="ups.timer.shutdown", - name="Load Shutdown Timer", + translation_key="ups_timer_shutdown", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -151,7 +151,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.test.interval": SensorEntityDescription( key="ups.test.interval", - name="Self-Test Interval", + translation_key="ups_test_interval", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,35 +159,35 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.test.result": SensorEntityDescription( key="ups.test.result", - name="Self-Test Result", + translation_key="ups_test_result", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.test.date": SensorEntityDescription( key="ups.test.date", - name="Self-Test Date", + translation_key="ups_test_date", icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.display.language": SensorEntityDescription( key="ups.display.language", - name="Language", + translation_key="ups_display_language", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.contacts": SensorEntityDescription( key="ups.contacts", - name="External Contacts", + translation_key="ups_contacts", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.efficiency": SensorEntityDescription( key="ups.efficiency", - name="Efficiency", + translation_key="ups_efficiency", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, @@ -196,7 +196,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.power": SensorEntityDescription( key="ups.power", - name="Current Apparent Power", + translation_key="ups_power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -205,7 +205,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.power.nominal": SensorEntityDescription( key="ups.power.nominal", - name="Nominal Power", + translation_key="ups_power_nominal", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.realpower": SensorEntityDescription( key="ups.realpower", - name="Current Real Power", + translation_key="ups_realpower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -222,7 +222,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.realpower.nominal": SensorEntityDescription( key="ups.realpower.nominal", - name="Nominal Real Power", + translation_key="ups_realpower_nominal", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -230,63 +230,63 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", - name="Beeper Status", + translation_key="ups_beeper_status", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.type": SensorEntityDescription( key="ups.type", - name="UPS Type", + translation_key="ups_type", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.watchdog.status": SensorEntityDescription( key="ups.watchdog.status", - name="Watchdog Status", + translation_key="ups_watchdog_status", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.auto": SensorEntityDescription( key="ups.start.auto", - name="Start on AC", + translation_key="ups_start_auto", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.battery": SensorEntityDescription( key="ups.start.battery", - name="Start on Battery", + translation_key="ups_start_battery", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.reboot": SensorEntityDescription( key="ups.start.reboot", - name="Reboot on Battery", + translation_key="ups_start_reboot", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.shutdown": SensorEntityDescription( key="ups.shutdown", - name="Shutdown Ability", + translation_key="ups_shutdown", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.charge": SensorEntityDescription( key="battery.charge", - name="Battery Charge", + translation_key="battery_charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), "battery.charge.low": SensorEntityDescription( key="battery.charge.low", - name="Low Battery Setpoint", + translation_key="battery_charge_low", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.charge.restart": SensorEntityDescription( key="battery.charge.restart", - name="Minimum Battery to Start", + translation_key="battery_charge_restart", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -302,7 +302,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.charge.warning": SensorEntityDescription( key="battery.charge.warning", - name="Warning Battery Setpoint", + translation_key="battery_charge_warning", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -310,12 +310,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.charger.status": SensorEntityDescription( key="battery.charger.status", - name="Charging Status", + translation_key="battery_charger_status", icon="mdi:information-outline", ), "battery.voltage": SensorEntityDescription( key="battery.voltage", - name="Battery Voltage", + translation_key="battery_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -324,7 +324,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.voltage.nominal": SensorEntityDescription( key="battery.voltage.nominal", - name="Nominal Battery Voltage", + translation_key="battery_voltage_nominal", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -332,7 +332,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.voltage.low": SensorEntityDescription( key="battery.voltage.low", - name="Low Battery Voltage", + translation_key="battery_voltage_low", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -340,7 +340,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.voltage.high": SensorEntityDescription( key="battery.voltage.high", - name="High Battery Voltage", + translation_key="battery_voltage_high", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -348,7 +348,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.capacity": SensorEntityDescription( key="battery.capacity", - name="Battery Capacity", + translation_key="battery_capacity", native_unit_of_measurement="Ah", icon="mdi:flash", entity_category=EntityCategory.DIAGNOSTIC, @@ -356,7 +356,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.current": SensorEntityDescription( key="battery.current", - name="Battery Current", + translation_key="battery_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -365,7 +365,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.current.total": SensorEntityDescription( key="battery.current.total", - name="Total Battery Current", + translation_key="battery_current_total", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -373,7 +373,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.temperature": SensorEntityDescription( key="battery.temperature", - name="Battery Temperature", + translation_key="battery_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -382,7 +382,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.runtime": SensorEntityDescription( key="battery.runtime", - name="Battery Runtime", + translation_key="battery_runtime", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -390,7 +390,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.runtime.low": SensorEntityDescription( key="battery.runtime.low", - name="Low Battery Runtime", + translation_key="battery_runtime_low", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -398,7 +398,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.runtime.restart": SensorEntityDescription( key="battery.runtime.restart", - name="Minimum Battery Runtime to Start", + translation_key="battery_runtime_restart", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -406,56 +406,56 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.alarm.threshold": SensorEntityDescription( key="battery.alarm.threshold", - name="Battery Alarm Threshold", + translation_key="battery_alarm_threshold", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.date": SensorEntityDescription( key="battery.date", - name="Battery Date", + translation_key="battery_date", icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.mfr.date": SensorEntityDescription( key="battery.mfr.date", - name="Battery Manuf. Date", + translation_key="battery_mfr_date", icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs": SensorEntityDescription( key="battery.packs", - name="Number of Batteries", + translation_key="battery_packs", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs.bad": SensorEntityDescription( key="battery.packs.bad", - name="Number of Bad Batteries", + translation_key="battery_packs_bad", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.type": SensorEntityDescription( key="battery.type", - name="Battery Chemistry", + translation_key="battery_type", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.sensitivity": SensorEntityDescription( key="input.sensitivity", - name="Input Power Sensitivity", + translation_key="input_sensitivity", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.transfer.low": SensorEntityDescription( key="input.transfer.low", - name="Low Voltage Transfer", + translation_key="input_transfer_low", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -463,7 +463,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.transfer.high": SensorEntityDescription( key="input.transfer.high", - name="High Voltage Transfer", + translation_key="input_transfer_high", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -471,21 +471,21 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.transfer.reason": SensorEntityDescription( key="input.transfer.reason", - name="Voltage Transfer Reason", + translation_key="input_transfer_reason", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.voltage": SensorEntityDescription( key="input.voltage", - name="Input Voltage", + translation_key="input_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), "input.voltage.nominal": SensorEntityDescription( key="input.voltage.nominal", - name="Nominal Input Voltage", + translation_key="input_voltage_nominal", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -493,7 +493,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.frequency": SensorEntityDescription( key="input.frequency", - name="Input Line Frequency", + translation_key="input_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -502,7 +502,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.frequency.nominal": SensorEntityDescription( key="input.frequency.nominal", - name="Nominal Input Line Frequency", + translation_key="input_frequency_nominal", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, @@ -510,14 +510,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.frequency.status": SensorEntityDescription( key="input.frequency.status", - name="Input Frequency Status", + translation_key="input_frequency_status", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.bypass.frequency": SensorEntityDescription( key="input.bypass.frequency", - name="Input Bypass Frequency", + translation_key="input_bypass_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -526,14 +526,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.bypass.phases": SensorEntityDescription( key="input.bypass.phases", - name="Input Bypass Phases", + translation_key="input_bypass_phases", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.current": SensorEntityDescription( key="input.current", - name="Input Current", + translation_key="input_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -542,14 +542,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.phases": SensorEntityDescription( key="input.phases", - name="Input Phases", + translation_key="input_phases", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.realpower": SensorEntityDescription( key="input.realpower", - name="Current Input Real Power", + translation_key="input_realpower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -558,7 +558,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.power.nominal": SensorEntityDescription( key="output.power.nominal", - name="Nominal Output Power", + translation_key="output_power_nominal", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -566,7 +566,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.current": SensorEntityDescription( key="output.current", - name="Output Current", + translation_key="output_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -575,7 +575,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.current.nominal": SensorEntityDescription( key="output.current.nominal", - name="Nominal Output Current", + translation_key="output_current_nominal", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -583,14 +583,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.voltage": SensorEntityDescription( key="output.voltage", - name="Output Voltage", + translation_key="output_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), "output.voltage.nominal": SensorEntityDescription( key="output.voltage.nominal", - name="Nominal Output Voltage", + translation_key="output_voltage_nominal", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -598,7 +598,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.frequency": SensorEntityDescription( key="output.frequency", - name="Output Frequency", + translation_key="output_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -607,7 +607,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.frequency.nominal": SensorEntityDescription( key="output.frequency.nominal", - name="Nominal Output Frequency", + translation_key="output_frequency_nominal", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, @@ -615,14 +615,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.phases": SensorEntityDescription( key="output.phases", - name="Output Phases", + translation_key="output_phases", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "output.power": SensorEntityDescription( key="output.power", - name="Output Apparent Power", + translation_key="output_power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -631,7 +631,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.realpower": SensorEntityDescription( key="output.realpower", - name="Current Output Real Power", + translation_key="output_realpower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -640,7 +640,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.realpower.nominal": SensorEntityDescription( key="output.realpower.nominal", - name="Nominal Output Real Power", + translation_key="output_realpower_nominal", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -648,21 +648,21 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ambient.humidity": SensorEntityDescription( key="ambient.humidity", - name="Ambient Humidity", + translation_key="ambient_humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", - name="Ambient Temperature", + translation_key="ambient_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), "watts": SensorEntityDescription( key="watts", - name="Watts", + translation_key="watts", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -717,6 +717,8 @@ async def async_setup_entry( class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity): """Representation of a sensor entity for NUT status values.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[dict[str, str]], @@ -729,7 +731,6 @@ class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], Sensor self.entity_description = sensor_description device_name = data.name.title() - self._attr_name = f"{device_name} {sensor_description.name}" self._attr_unique_id = f"{unique_id}_{sensor_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 70ecbfb6d2e..9ac05546b32 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -33,5 +33,89 @@ } } } + }, + "entity": { + "sensor": { + "ambient_humidity": { "name": "Ambient humidity" }, + "ambient_temperature": { "name": "Ambient temperature" }, + "battery_alarm_threshold": { "name": "Battery alarm threshold" }, + "battery_capacity": { "name": "Battery capacity" }, + "battery_charge": { "name": "Battery charge" }, + "battery_charge_low": { "name": "Low battery setpoint" }, + "battery_charge_restart": { "name": "Minimum battery to start" }, + "battery_charge_warning": { "name": "Warning battery setpoint" }, + "battery_charger_status": { "name": "Charging status" }, + "battery_current": { "name": "Battery current" }, + "battery_current_total": { "name": "Total battery current" }, + "battery_date": { "name": "Battery date" }, + "battery_mfr_date": { "name": "Battery manuf. date" }, + "battery_packs": { "name": "Number of batteries" }, + "battery_packs_bad": { "name": "Number of bad batteries" }, + "battery_runtime": { "name": "Battery runtime" }, + "battery_runtime_low": { "name": "Low battery runtime" }, + "battery_runtime_restart": { "name": "Minimum battery runtime to start" }, + "battery_temperature": { "name": "Battery temperature" }, + "battery_type": { "name": "Battery chemistry" }, + "battery_voltage": { "name": "Battery voltage" }, + "battery_voltage_high": { "name": "High battery voltage" }, + "battery_voltage_low": { "name": "Low battery voltage" }, + "battery_voltage_nominal": { "name": "Nominal battery voltage" }, + "input_bypass_frequency": { "name": "Input bypass frequency" }, + "input_bypass_phases": { "name": "Input bypass phases" }, + "input_current": { "name": "Input current" }, + "input_frequency": { "name": "Input line frequency" }, + "input_frequency_nominal": { "name": "Nominal input line frequency" }, + "input_frequency_status": { "name": "Input frequency status" }, + "input_phases": { "name": "Input phases" }, + "input_realpower": { "name": "Current input real power" }, + "input_sensitivity": { "name": "Input power sensitivity" }, + "input_transfer_high": { "name": "High voltage transfer" }, + "input_transfer_low": { "name": "Low voltage transfer" }, + "input_transfer_reason": { "name": "Voltage transfer reason" }, + "input_voltage": { "name": "Input voltage" }, + "input_voltage_nominal": { "name": "Nominal input voltage" }, + "output_current": { "name": "Output current" }, + "output_current_nominal": { "name": "Nominal output current" }, + "output_frequency": { "name": "Output frequency" }, + "output_frequency_nominal": { "name": "Nominal output frequency" }, + "output_phases": { "name": "Output phases" }, + "output_power": { "name": "Output apparent power" }, + "output_power_nominal": { "name": "Nominal output power" }, + "output_realpower": { "name": "Current output real power" }, + "output_realpower_nominal": { "name": "Nominal output real power" }, + "output_voltage": { "name": "Output voltage" }, + "output_voltage_nominal": { "name": "Nominal output voltage" }, + "ups_alarm": { "name": "Alarms" }, + "ups_beeper_status": { "name": "Beeper status" }, + "ups_contacts": { "name": "External contacts" }, + "ups_delay_reboot": { "name": "UPS reboot delay" }, + "ups_delay_shutdown": { "name": "UPS shutdown delay" }, + "ups_delay_start": { "name": "Load restart delay" }, + "ups_display_language": { "name": "Language" }, + "ups_efficiency": { "name": "Efficiency" }, + "ups_id": { "name": "System identifier" }, + "ups_load": { "name": "Load" }, + "ups_load_high": { "name": "Overload setting" }, + "ups_power": { "name": "Current apparent power" }, + "ups_power_nominal": { "name": "Nominal power" }, + "ups_realpower": { "name": "Current real power" }, + "ups_realpower_nominal": { "name": "Nominal real power" }, + "ups_shutdown": { "name": "Shutdown ability" }, + "ups_start_auto": { "name": "Start on ac" }, + "ups_start_battery": { "name": "Start on battery" }, + "ups_start_reboot": { "name": "Reboot on battery" }, + "ups_status": { "name": "Status data" }, + "ups_status_display": { "name": "Status" }, + "ups_temperature": { "name": "UPS temperature" }, + "ups_test_date": { "name": "Self-test date" }, + "ups_test_interval": { "name": "Self-test interval" }, + "ups_test_result": { "name": "Self-test result" }, + "ups_timer_reboot": { "name": "Load reboot timer" }, + "ups_timer_shutdown": { "name": "Load shutdown timer" }, + "ups_timer_start": { "name": "Load start timer" }, + "ups_type": { "name": "UPS type" }, + "ups_watchdog_status": { "name": "Watchdog status" }, + "watts": { "name": "Watts" } + } } } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index ecb95a1f9e8..9edf6e61751 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -108,6 +108,7 @@ if TYPE_CHECKING: class NWSWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__( @@ -154,11 +155,6 @@ class NWSWeather(WeatherEntity): self.async_write_ha_state() - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def name(self) -> str: """Return the name of the station.""" diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 664ad033cfe..b9109645943 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -34,7 +34,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE_ID = "route_id" DEFAULT_NAME = "OASA Telematics" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(seconds=60) @@ -67,6 +67,7 @@ class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" _attr_attribution = "Data retrieved from telematics.oasa.gr" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route_id, name): """Initialize the sensor.""" @@ -121,11 +122,6 @@ class OASATelematicsSensor(SensorEntity): ) return {k: v for k, v in params.items() if v} - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from OASA API and update the states.""" self.data.update() diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 810b24dca20..12cb9e25f84 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1,9 +1,11 @@ """The Obihai integration.""" from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import PLATFORMS +from .connectivity import ObihaiConnection +from .const import LOGGER, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -13,6 +15,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + version = entry.version + + LOGGER.debug("Migrating from version %s", version) + if version != 2: + requester = ObihaiConnection( + entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + await hass.async_add_executor_job(requester.update) + + new_unique_id = await hass.async_add_executor_job( + requester.pyobihai.get_device_mac + ) + hass.config_entries.async_update_entry(entry, unique_id=new_unique_id) + + entry.version = 2 + + LOGGER.info("Migration to version %s successful", entry.version) + + 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/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 2f8dd0075b8..1a7d29fadba 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -1,10 +1,14 @@ """Config flow to configure the Obihai integration.""" + from __future__ import annotations +from socket import gaierror, gethostbyname from typing import Any +from pyobihai import PyObihai import voluptuous as vol +from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -16,11 +20,11 @@ from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, - vol.Optional( + vol.Required( CONF_USERNAME, default=DEFAULT_USERNAME, ): str, - vol.Optional( + vol.Required( CONF_PASSWORD, default=DEFAULT_PASSWORD, ): str, @@ -28,48 +32,122 @@ DATA_SCHEMA = vol.Schema( ) -async def async_validate_creds(hass: HomeAssistant, user_input: dict[str, Any]) -> bool: +async def async_validate_creds( + hass: HomeAssistant, user_input: dict[str, Any] +) -> PyObihai | None: """Manage Obihai options.""" - return await hass.async_add_executor_job( - validate_auth, - user_input[CONF_HOST], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) + + if user_input[CONF_USERNAME] and user_input[CONF_PASSWORD]: + return await hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + # Don't bother authenticating if we've already determined the credentials are invalid + return None class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Obihai.""" - VERSION = 1 + VERSION = 2 + discovery_schema: vol.Schema | None = None + _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + ip: str | None = None if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await async_validate_creds(self.hass, user_input): - return self.async_create_entry( - title=user_input[CONF_HOST], - data=user_input, - ) - errors["base"] = "cannot_connect" + try: + ip = gethostbyname(user_input[CONF_HOST]) + except gaierror: + errors["base"] = "cannot_connect" - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + if ip: + if pyobihai := await async_validate_creds(self.hass, user_input): + device_mac = await self.hass.async_add_executor_job( + pyobihai.get_device_mac + ) + await self.async_set_unique_id(device_mac) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + errors["base"] = "invalid_auth" + + data_schema = self.discovery_schema or DATA_SCHEMA return self.async_show_form( step_id="user", errors=errors, - data_schema=data_schema, + data_schema=self.add_suggested_values_to_schema(data_schema, user_input), ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Prepare configuration for a DHCP discovered Obihai.""" + + self._dhcp_discovery_info = discovery_info + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Attempt to confirm.""" + assert self._dhcp_discovery_info + await self.async_set_unique_id(self._dhcp_discovery_info.macaddress) + self._abort_if_unique_id_configured() + + if user_input is None: + credentials = { + CONF_HOST: self._dhcp_discovery_info.ip, + CONF_PASSWORD: DEFAULT_PASSWORD, + CONF_USERNAME: DEFAULT_USERNAME, + } + if await async_validate_creds(self.hass, credentials): + self.discovery_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, credentials + ) + else: + self.discovery_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, + { + CONF_HOST: self._dhcp_discovery_info.ip, + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + + # Show the confirmation dialog + return self.async_show_form( + step_id="dhcp_confirm", + data_schema=self.discovery_schema, + description_placeholders={CONF_HOST: self._dhcp_discovery_info.ip}, + ) + + return await self.async_step_user(user_input=user_input) + # DEPRECATED async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Handle a flow initialized by importing a config.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - if await async_validate_creds(self.hass, config): + + try: + _ = gethostbyname(config[CONF_HOST]) + except gaierror: + return self.async_abort(reason="cannot_connect") + + if pyobihai := await async_validate_creds(self.hass, config): + device_mac = await self.hass.async_add_executor_job(pyobihai.get_device_mac) + await self.async_set_unique_id(device_mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( title=config.get(CONF_NAME, config[CONF_HOST]), data={ @@ -79,4 +157,4 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - return self.async_abort(reason="cannot_connect") + return self.async_abort(reason="invalid_auth") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 93eeccd1bb7..071390f1ad9 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -1,4 +1,5 @@ """Support for Obihai Connectivity.""" + from __future__ import annotations from pyobihai import PyObihai @@ -12,6 +13,7 @@ def get_pyobihai( password: str, ) -> PyObihai: """Retrieve an authenticated PyObihai.""" + return PyObihai(host, username, password) @@ -19,16 +21,17 @@ def validate_auth( host: str, username: str, password: str, -) -> bool: +) -> PyObihai | None: """Test if the given setting works as expected.""" + obi = get_pyobihai(host, username, password) login = obi.check_account() if not login: LOGGER.debug("Invalid credentials") - return False + return None - return True + return obi class ObihaiConnection: @@ -53,6 +56,7 @@ class ObihaiConnection: def update(self) -> bool: """Validate connection and retrieve a list of sensors.""" + if not self.pyobihai: self.pyobihai = get_pyobihai(self.host, self.username, self.password) diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 939c170f989..2907f3f179d 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -3,6 +3,11 @@ "name": "Obihai", "codeowners": ["@dshokouhi", "@ejpenney"], "config_flow": true, + "dhcp": [ + { + "macaddress": "9CADEF*" + } + ], "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", "loggers": ["pyobihai"], diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index fb673675ad7..1b91cd60654 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -7,10 +7,19 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" } + }, + "dhcp_confirm": { + "description": "Do you want to set up {host}?", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "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/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index e255357646b..b405140bc32 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -37,8 +37,8 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ... OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, - name=f"Sensed {id}", read_mode=READ_MODE_BOOL, + translation_key=f"sensed_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -46,8 +46,8 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ... OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, - name=f"Sensed {id}", read_mode=READ_MODE_BOOL, + translation_key=f"sensed_{id}", ) for id in DEVICE_KEYS_0_7 ), @@ -55,8 +55,8 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ... OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, - name=f"Sensed {id}", read_mode=READ_MODE_BOOL, + translation_key=f"sensed_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -69,10 +69,10 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { OneWireBinarySensorEntityDescription( key=f"hub/short.{id}", entity_registry_enabled_default=False, - name=f"Hub Short on Branch {id}", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key=f"hub_short_{id}", ) for id in DEVICE_KEYS_0_3 ), @@ -120,14 +120,12 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: continue for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) - name = f"{device_id} {description.name}" entities.append( OneWireBinarySensor( description=description, device_id=device_id, device_file=device_file, device_info=device_info, - name=name, owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index 59ceb34d6fd..f2a56e513f2 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -27,6 +27,7 @@ class OneWireEntity(Entity): """Implementation of a 1-Wire entity.""" entity_description: OneWireEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -34,7 +35,6 @@ class OneWireEntity(Entity): device_id: str, device_info: DeviceInfo, device_file: str, - name: str, owproxy: protocol._Proxy, ) -> None: """Initialize the entity.""" @@ -42,7 +42,6 @@ class OneWireEntity(Entity): self._last_update_success = True self._attr_unique_id = f"/{device_id}/{description.key}" self._attr_device_info = device_info - self._attr_name = name self._device_file = device_file self._state: StateType = None self._value_raw: float | None = None diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index a21a093e0a1..65bd542fc30 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -70,10 +70,10 @@ def _get_sensor_precision_family_28(device_id: str, options: Mapping[str, Any]) SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION = OneWireSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ) _LOGGER = logging.getLogger(__name__) @@ -86,19 +86,19 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { key="TAI8570/temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), OneWireSensorEntityDescription( key="TAI8570/pressure", device_class=SensorDeviceClass.PRESSURE, entity_registry_enabled_default=False, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -108,102 +108,102 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { key="humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity", ), OneWireSensorEntityDescription( key="HIH3600/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HIH3600", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_hih3600", ), OneWireSensorEntityDescription( key="HIH4000/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HIH4000", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_hih4000", ), OneWireSensorEntityDescription( key="HIH5030/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HIH5030", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_hih5030", ), OneWireSensorEntityDescription( key="HTM1735/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HTM1735", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_htm1735", ), OneWireSensorEntityDescription( key="B1-R1-A/pressure", device_class=SensorDeviceClass.PRESSURE, entity_registry_enabled_default=False, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), OneWireSensorEntityDescription( key="S3-R1-A/illuminance", device_class=SensorDeviceClass.ILLUMINANCE, entity_registry_enabled_default=False, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="illuminance", ), OneWireSensorEntityDescription( key="VAD", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="Voltage VAD", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vad", ), OneWireSensorEntityDescription( key="VDD", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="Voltage VDD", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vdd", ), OneWireSensorEntityDescription( key="vis", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="vis", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vis", ), ), "28": ( OneWireSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, override_key=_get_sensor_precision_family_28, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), ), "30": ( @@ -212,29 +212,29 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { key="typeX/temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, - name="Thermocouple temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, override_key=lambda d, o: "typeK/temperature", state_class=SensorStateClass.MEASUREMENT, + translation_key="thermocouple_temperature_k", ), OneWireSensorEntityDescription( key="volt", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage", ), OneWireSensorEntityDescription( key="vis", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="vis", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vis_gradient", ), ), "3B": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -242,10 +242,10 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { "1D": tuple( OneWireSensorEntityDescription( key=f"counter.{id}", - name=f"Counter {id}", native_unit_of_measurement="count", read_mode=READ_MODE_INT, state_class=SensorStateClass.TOTAL_INCREASING, + translation_key=f"counter_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -258,36 +258,36 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { OneWireSensorEntityDescription( key="humidity/humidity_corrected", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity", ), OneWireSensorEntityDescription( key="humidity/humidity_raw", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity Raw", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_raw", ), OneWireSensorEntityDescription( key="humidity/temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), ), "HB_MOISTURE_METER": tuple( OneWireSensorEntityDescription( key=f"moisture/sensor.{id}", device_class=SensorDeviceClass.PRESSURE, - name=f"Moisture {id}", native_unit_of_measurement=UnitOfPressure.CBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key=f"moisture_{id}", ) for id in DEVICE_KEYS_0_3 ), @@ -300,52 +300,52 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { OneWireSensorEntityDescription( key="EDS0066/temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0066/pressure", device_class=SensorDeviceClass.PRESSURE, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), ), "EDS0068": ( OneWireSensorEntityDescription( key="EDS0068/temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0068/pressure", device_class=SensorDeviceClass.PRESSURE, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), OneWireSensorEntityDescription( key="EDS0068/light", device_class=SensorDeviceClass.ILLUMINANCE, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="illuminance", ), OneWireSensorEntityDescription( key="EDS0068/humidity", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity", ), ), } @@ -412,7 +412,8 @@ def get_entities( description = copy.deepcopy(description) description.device_class = SensorDeviceClass.HUMIDITY description.native_unit_of_measurement = PERCENTAGE - description.name = f"Wetness {s_id}" + description.translation_key = f"wetness_{s_id}" + _LOGGER.info(description.translation_key) override_key = None if description.override_key: override_key = description.override_key(device_id, options) @@ -420,7 +421,6 @@ def get_entities( os.path.split(device.path)[0], override_key or description.key, ) - name = f"{device_id} {description.name}" if family == "12": # We need to check if there is TAI8570 plugged in try: @@ -438,7 +438,6 @@ def get_entities( device_id=device_id, device_file=device_file, device_info=device_info, - name=name, owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 734971cb2a1..2a7bd307ff8 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -16,6 +16,233 @@ } } }, + "entity": { + "binary_sensor": { + "sensed_a": { + "name": "Sensed A" + }, + "sensed_b": { + "name": "Sensed B" + }, + "sensed_0": { + "name": "Sensed 0" + }, + "sensed_1": { + "name": "Sensed 1" + }, + "sensed_2": { + "name": "Sensed 2" + }, + "sensed_3": { + "name": "Sensed 3" + }, + "sensed_4": { + "name": "Sensed 4" + }, + "sensed_5": { + "name": "Sensed 5" + }, + "sensed_6": { + "name": "Sensed 6" + }, + "sensed_7": { + "name": "Sensed 7" + }, + "hub_short_0": { + "name": "Hub short on branch 0" + }, + "hub_short_1": { + "name": "Hub short on branch 1" + }, + "hub_short_2": { + "name": "Hub short on branch 2" + }, + "hub_short_3": { + "name": "Hub short on branch 3" + } + }, + "sensor": { + "counter_a": { + "name": "Counter A" + }, + "counter_b": { + "name": "Counter B" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "humidity_hih3600": { + "name": "HIH3600 humidity" + }, + "humidity_hih4000": { + "name": "HIH4000 humidity" + }, + "humidity_hih5030": { + "name": "HIH5030 humidity" + }, + "humidity_htm1735": { + "name": "HTM1735 humidity" + }, + "humidity_raw": { + "name": "Raw humidity" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "moisture_1": { + "name": "Moisture 1" + }, + "moisture_2": { + "name": "Moisture 2" + }, + "moisture_3": { + "name": "Moisture 3" + }, + "moisture_4": { + "name": "Moisture 4" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "thermocouple_temperature_k": { + "name": "Thermocouple K temperature" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "voltage_vad": { + "name": "VAD voltage" + }, + "voltage_vdd": { + "name": "VDD voltage" + }, + "voltage_vis": { + "name": "VIS voltage difference" + }, + "voltage_vis_gradient": { + "name": "VIS voltage gradient" + }, + "wetness_0": { + "name": "Wetness 0" + }, + "wetness_1": { + "name": "Wetness 1" + }, + "wetness_2": { + "name": "Wetness 2" + }, + "wetness_3": { + "name": "Wetness 3" + } + }, + "switch": { + "hub_branch_0": { + "name": "Hub branch 0" + }, + "hub_branch_1": { + "name": "Hub branch 1" + }, + "hub_branch_2": { + "name": "Hub branch 2" + }, + "hub_branch_3": { + "name": "Hub branch 3" + }, + "iad": { + "name": "Current A/D control" + }, + "latch_0": { + "name": "Latch 0" + }, + "latch_1": { + "name": "Latch 1" + }, + "latch_2": { + "name": "Latch 2" + }, + "latch_3": { + "name": "Latch 3" + }, + "latch_4": { + "name": "Latch 4" + }, + "latch_5": { + "name": "Latch 5" + }, + "latch_6": { + "name": "Latch 6" + }, + "latch_7": { + "name": "Latch 7" + }, + "latch_a": { + "name": "Latch A" + }, + "latch_b": { + "name": "Latch B" + }, + "leaf_sensor_0": { + "name": "Leaf sensor 0" + }, + "leaf_sensor_1": { + "name": "Leaf sensor 1" + }, + "leaf_sensor_2": { + "name": "Leaf sensor 2" + }, + "leaf_sensor_3": { + "name": "Leaf sensor 3" + }, + "moisture_sensor_0": { + "name": "Moisture sensor 0" + }, + "moisture_sensor_1": { + "name": "Moisture sensor 1" + }, + "moisture_sensor_2": { + "name": "Moisture sensor 2" + }, + "moisture_sensor_3": { + "name": "Moisture sensor 3" + }, + "pio": { + "name": "Programmed input-output" + }, + "pio_0": { + "name": "Programmed input-output 0" + }, + "pio_1": { + "name": "Programmed input-output 1" + }, + "pio_2": { + "name": "Programmed input-output 2" + }, + "pio_3": { + "name": "Programmed input-output 3" + }, + "pio_4": { + "name": "Programmed input-output 4" + }, + "pio_5": { + "name": "Programmed input-output 5" + }, + "pio_6": { + "name": "Programmed input-output 6" + }, + "pio_7": { + "name": "Programmed input-output 7" + }, + "pio_a": { + "name": "Programmed input-output A" + }, + "pio_b": { + "name": "Programmed input-output B" + } + } + }, "options": { "error": { "device_not_selected": "Select devices to configure" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 86172a87512..986be11d169 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -32,8 +32,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key="PIO", entity_registry_enabled_default=False, - name="PIO", read_mode=READ_MODE_BOOL, + translation_key="pio", ), ), "12": tuple( @@ -41,8 +41,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"PIO.{id}", entity_registry_enabled_default=False, - name=f"PIO {id}", read_mode=READ_MODE_BOOL, + translation_key=f"pio_{id.lower()}", ) for id in DEVICE_KEYS_A_B ] @@ -50,8 +50,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"latch.{id}", entity_registry_enabled_default=False, - name=f"Latch {id}", read_mode=READ_MODE_BOOL, + translation_key=f"latch_{id.lower()}", ) for id in DEVICE_KEYS_A_B ] @@ -61,8 +61,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { key="IAD", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, - name="IAD", read_mode=READ_MODE_BOOL, + translation_key="iad", ), ), "29": tuple( @@ -70,8 +70,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"PIO.{id}", entity_registry_enabled_default=False, - name=f"PIO {id}", read_mode=READ_MODE_BOOL, + translation_key=f"pio_{id}", ) for id in DEVICE_KEYS_0_7 ] @@ -79,8 +79,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"latch.{id}", entity_registry_enabled_default=False, - name=f"Latch {id}", read_mode=READ_MODE_BOOL, + translation_key=f"latch_{id}", ) for id in DEVICE_KEYS_0_7 ] @@ -89,8 +89,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"PIO.{id}", entity_registry_enabled_default=False, - name=f"PIO {id}", read_mode=READ_MODE_BOOL, + translation_key=f"pio_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -104,9 +104,9 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"hub/branch.{id}", entity_registry_enabled_default=False, - name=f"Hub Branch {id} Enable", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, + translation_key=f"hub_branch_{id}", ) for id in DEVICE_KEYS_0_3 ), @@ -115,9 +115,9 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"moisture/is_leaf.{id}", entity_registry_enabled_default=False, - name=f"Leaf Sensor {id} Enable", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, + translation_key=f"leaf_sensor_{id}", ) for id in DEVICE_KEYS_0_3 ] @@ -125,9 +125,9 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"moisture/is_moisture.{id}", entity_registry_enabled_default=False, - name=f"Moisture Sensor {id} Enable", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, + translation_key=f"moisture_sensor_{id}", ) for id in DEVICE_KEYS_0_3 ] @@ -177,14 +177,12 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: continue for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) - name = f"{device_id} {description.name}" entities.append( OneWireSwitch( description=description, device_id=device_id, device_file=device_file, device_info=device_info, - name=name, owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3e67d4e27da..6f76142106a 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( @@ -138,7 +138,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 88289eb90b0..46f8603c5f1 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ CONF_PROMPT = "prompt" DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 5999eb91580..0e918103cd2 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -20,7 +20,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by openSenseMap" CONF_STATION_ID = "station_id" @@ -59,6 +58,8 @@ async def async_setup_platform( class OpenSenseMapQuality(AirQualityEntity): """Implementation of an openSenseMap air quality entity.""" + _attr_attribution = "Data provided by openSenseMap" + def __init__(self, name, osm): """Initialize the air quality entity.""" self._name = name @@ -79,11 +80,6 @@ class OpenSenseMapQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._osm.api.pm10 - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self): """Get the latest data from the openSenseMap API.""" await self._osm.async_update() diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index a25ff8b46bc..5942d67b50d 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -2,91 +2,20 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -import dataclasses -from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp import python_otbr_api -from python_otbr_api import tlv_parser -from python_otbr_api.pskc import compute_pskc from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN - -_R = TypeVar("_R") -_P = ParamSpec("_P") - -INSECURE_NETWORK_KEYS = ( - # Thread web UI default - bytes.fromhex("00112233445566778899AABBCCDDEEFF"), -) - -INSECURE_PASSPHRASES = ( - # Thread web UI default - "j01Nme", - # Thread documentation default - "J01NME", -) - - -def _handle_otbr_error( - func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] -) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: - """Handle OTBR errors.""" - - @wraps(func) - async def _func(self: OTBRData, *args: _P.args, **kwargs: _P.kwargs) -> _R: - try: - return await func(self, *args, **kwargs) - except python_otbr_api.OTBRError as exc: - raise HomeAssistantError("Failed to call OTBR API") from exc - - return _func - - -@dataclasses.dataclass -class OTBRData: - """Container for OTBR data.""" - - url: str - api: python_otbr_api.OTBR - - @_handle_otbr_error - async def set_enabled(self, enabled: bool) -> None: - """Enable or disable the router.""" - return await self.api.set_enabled(enabled) - - @_handle_otbr_error - async def get_active_dataset_tlvs(self) -> bytes | None: - """Get current active operational dataset in TLVS format, or None.""" - return await self.api.get_active_dataset_tlvs() - - @_handle_otbr_error - async def create_active_dataset( - self, dataset: python_otbr_api.OperationalDataSet - ) -> None: - """Create an active operational dataset.""" - return await self.api.create_active_dataset(dataset) - - @_handle_otbr_error - async def set_active_dataset_tlvs(self, dataset: bytes) -> None: - """Set current active operational dataset in TLVS format.""" - await self.api.set_active_dataset_tlvs(dataset) - - @_handle_otbr_error - async def get_extended_address(self) -> bytes: - """Get extended address (EUI-64).""" - return await self.api.get_extended_address() +from .util import OTBRData, update_issues async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,54 +24,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _warn_on_default_network_settings( - hass: HomeAssistant, entry: ConfigEntry, dataset_tlvs: bytes -) -> None: - """Warn user if insecure default network settings are used.""" - dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) - insecure = False - - if ( - network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) - ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: - insecure = True - if ( - not insecure - and tlv_parser.MeshcopTLVType.EXTPANID in dataset - and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset - and tlv_parser.MeshcopTLVType.PSKC in dataset - ): - ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] - network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] - pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) - for passphrase in INSECURE_PASSPHRASES: - if pskc == compute_pskc(ext_pan_id, network_name, passphrase): - insecure = True - break - - if insecure: - ir.async_create_issue( - hass, - DOMAIN, - f"insecure_thread_network_{entry.entry_id}", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="insecure_thread_network", - ) - else: - ir.async_delete_issue( - hass, - DOMAIN, - f"insecure_thread_network_{entry.entry_id}", - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) - otbrdata = OTBRData(entry.data["url"], api) + otbrdata = OTBRData(entry.data["url"], api, entry.entry_id) try: dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( @@ -152,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) as err: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: - _warn_on_default_network_settings(hass, entry, dataset_tlvs) + await update_issues(hass, otbrdata, dataset_tlvs) await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 8e9050ca9f4..c10a2417dc6 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -1,7 +1,7 @@ { "domain": "otbr", "name": "Open Thread Border Router", - "after_dependencies": ["hassio", "zha"], + "after_dependencies": ["hassio", "homeassistant_yellow", "zha"], "codeowners": ["@home-assistant/core"], "config_flow": true, "dependencies": ["homeassistant_hardware", "thread"], diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index f2efea0c1e8..129cbec4468 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -19,6 +19,10 @@ "insecure_thread_network": { "title": "Insecure Thread network settings detected", "description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network." + }, + "otbr_zha_channel_collision": { + "title": "OTBR and ZHA share the same radio but use different channels", + "description": "When OTBR and ZHA share the radio, they must use the same network channel.\n\nIf OTBR and ZHA attempt to connect to networks on different channels, neither Thread/Matter nor Zigbee will work.\n\nOTBR is configured with a Thread network on channel {otbr_channel}, ZHA is configured with a Zigbee network on channel {zha_channel}." } } } diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index b1a3ee11b82..b2ce05f280c 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -1,13 +1,98 @@ """Utility functions for the Open Thread Border Router integration.""" from __future__ import annotations +from collections.abc import Callable, Coroutine import contextlib +import dataclasses +from functools import wraps +from typing import Any, Concatenate, ParamSpec, TypeVar + +import python_otbr_api +from python_otbr_api import tlv_parser +from python_otbr_api.pskc import compute_pskc from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( is_multiprotocol_url, + multi_pan_addon_using_device, ) +from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO from homeassistant.components.zha import api as zha_api from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN + +_R = TypeVar("_R") +_P = ParamSpec("_P") + +INFO_URL_SKY_CONNECT = ( + "https://skyconnect.home-assistant.io/multiprotocol-channel-missmatch" +) +INFO_URL_YELLOW = "https://yellow.home-assistant.io/multiprotocol-channel-missmatch" + +INSECURE_NETWORK_KEYS = ( + # Thread web UI default + bytes.fromhex("00112233445566778899AABBCCDDEEFF"), +) + +INSECURE_PASSPHRASES = ( + # Thread web UI default + "j01Nme", + # Thread documentation default + "J01NME", +) + + +def _handle_otbr_error( + func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] +) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: + """Handle OTBR errors.""" + + @wraps(func) + async def _func(self: OTBRData, *args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except python_otbr_api.OTBRError as exc: + raise HomeAssistantError("Failed to call OTBR API") from exc + + return _func + + +@dataclasses.dataclass +class OTBRData: + """Container for OTBR data.""" + + url: str + api: python_otbr_api.OTBR + entry_id: str + + @_handle_otbr_error + async def set_enabled(self, enabled: bool) -> None: + """Enable or disable the router.""" + return await self.api.set_enabled(enabled) + + @_handle_otbr_error + async def get_active_dataset_tlvs(self) -> bytes | None: + """Get current active operational dataset in TLVS format, or None.""" + return await self.api.get_active_dataset_tlvs() + + @_handle_otbr_error + async def create_active_dataset( + self, dataset: python_otbr_api.OperationalDataSet + ) -> None: + """Create an active operational dataset.""" + return await self.api.create_active_dataset(dataset) + + @_handle_otbr_error + async def set_active_dataset_tlvs(self, dataset: bytes) -> None: + """Set current active operational dataset in TLVS format.""" + await self.api.set_active_dataset_tlvs(dataset) + + @_handle_otbr_error + async def get_extended_address(self) -> bytes: + """Get extended address (EUI-64).""" + return await self.api.get_extended_address() def _get_zha_url(hass: HomeAssistant) -> str | None: @@ -41,3 +126,104 @@ async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: return None return await _get_zha_channel(hass) + + +async def _warn_on_channel_collision( + hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes +) -> None: + """Warn user if OTBR and ZHA attempt to use different channels.""" + + def delete_issue() -> None: + ir.async_delete_issue( + hass, + DOMAIN, + f"otbr_zha_channel_collision_{otbrdata.entry_id}", + ) + + if (allowed_channel := await get_allowed_channel(hass, otbrdata.url)) is None: + delete_issue() + return + + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + + if (channel_s := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + delete_issue() + return + try: + channel = int(channel_s, 16) + except ValueError: + delete_issue() + return + + if channel == allowed_channel: + delete_issue() + return + + yellow = await multi_pan_addon_using_device(hass, YELLOW_RADIO) + learn_more_url = INFO_URL_YELLOW if yellow else INFO_URL_SKY_CONNECT + + ir.async_create_issue( + hass, + DOMAIN, + f"otbr_zha_channel_collision_{otbrdata.entry_id}", + is_fixable=False, + is_persistent=False, + learn_more_url=learn_more_url, + severity=ir.IssueSeverity.WARNING, + translation_key="otbr_zha_channel_collision", + translation_placeholders={ + "otbr_channel": str(channel), + "zha_channel": str(allowed_channel), + }, + ) + + +def _warn_on_default_network_settings( + hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes +) -> None: + """Warn user if insecure default network settings are used.""" + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + insecure = False + + if ( + network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) + ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: + insecure = True + if ( + not insecure + and tlv_parser.MeshcopTLVType.EXTPANID in dataset + and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset + and tlv_parser.MeshcopTLVType.PSKC in dataset + ): + ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] + network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] + pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) + for passphrase in INSECURE_PASSPHRASES: + if pskc == compute_pskc(ext_pan_id, network_name, passphrase): + insecure = True + break + + if insecure: + ir.async_create_issue( + hass, + DOMAIN, + f"insecure_thread_network_{otbrdata.entry_id}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="insecure_thread_network", + ) + else: + ir.async_delete_issue( + hass, + DOMAIN, + f"insecure_thread_network_{otbrdata.entry_id}", + ) + + +async def update_issues( + hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes +) -> None: + """Raise or clear repair issues related to network settings.""" + await _warn_on_channel_collision(hass, otbrdata, dataset_tlvs) + _warn_on_default_network_settings(hass, otbrdata, dataset_tlvs) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index cd4f8875e7b..2189df363ba 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,5 +1,4 @@ """Websocket API for OTBR.""" -from typing import TYPE_CHECKING import python_otbr_api from python_otbr_api import tlv_parser @@ -11,10 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_CHANNEL, DOMAIN -from .util import get_allowed_channel - -if TYPE_CHECKING: - from . import OTBRData +from .util import OTBRData, get_allowed_channel, update_issues @callback @@ -109,6 +105,9 @@ async def websocket_create_network( await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + # Update repair issues + await update_issues(hass, data, dataset_tlvs) + connection.send_result(msg["id"]) @@ -167,6 +166,9 @@ async def websocket_set_network( connection.send_error(msg["id"], "set_enabled_failed", str(exc)) return + # Update repair issues + await update_issues(hass, data, bytes.fromhex(dataset_tlv)) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 499c9b129f1..7c7c30df970 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -17,7 +17,6 @@ DEFAULT_NAME = "OTP Sensor" TIME_STEP = 30 # Default time step assumed by Google Authenticator -ICON = "mdi:update" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,6 +43,7 @@ async def async_setup_platform( class TOTPSensor(SensorEntity): """Representation of a TOTP sensor.""" + _attr_icon = "mdi:update" _attr_should_poll = False def __init__(self, name, token): @@ -76,8 +76,3 @@ class TOTPSensor(SensorEntity): def native_value(self): """Return the state of the sensor.""" return self._state - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 6086ee1efd8..560493888d4 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_when_setup +from homeassistant.util.json import json_loads from .config_flow import CONF_SECRET from .const import DOMAIN @@ -133,10 +134,11 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]["context"] - async def async_handle_mqtt_message(msg): + @callback + def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(msg.payload) + message = json_loads(msg.payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index a5e56d00731..ba11250f83e 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -188,7 +188,7 @@ class PersonStore(Store): return {"items": old_data["persons"]} -class PersonStorageCollection(collection.StorageCollection): +class PersonStorageCollection(collection.DictStorageCollection): """Person collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -197,15 +197,14 @@ class PersonStorageCollection(collection.StorageCollection): def __init__( self, store: Store, - logger: logging.Logger, id_manager: collection.IDManager, yaml_collection: collection.YamlCollection, ) -> None: """Initialize a person storage collection.""" - super().__init__(store, logger, id_manager) + super().__init__(store, id_manager) self.yaml_collection = yaml_collection - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused onboarding to create invalid person objects. @@ -271,16 +270,16 @@ class PersonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) user_id = update_data.get(CONF_USER_ID) - if user_id is not None and user_id != data.get(CONF_USER_ID): + if user_id is not None and user_id != item.get(CONF_USER_ID): await self._validate_user_id(user_id) - return {**data, **update_data} + return {**item, **update_data} async def _validate_user_id(self, user_id): """Validate the used user_id.""" @@ -337,7 +336,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) storage_collection = PersonStorageCollection( PersonStore(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, yaml_collection, ) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 49f1697adc6..96cdd7ab105 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -16,9 +16,9 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -64,6 +64,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Setting up %s integration with host %s", DOMAIN, host) + name_to_key = { + "Core Update Available": "core_update_available", + "Web Update Available": "web_update_available", + "FTL Update Available": "ftl_update_available", + "Status": "status", + "Ads Blocked Today": "ads_blocked_today", + "Ads Percentage Blocked Today": "ads_percentage_today", + "Seen Clients": "clients_ever_seen", + "DNS Queries Today": "dns_queries_today", + "Domains Blocked": "domains_being_blocked", + "DNS Queries Cached": "queries_cached", + "DNS Queries Forwarded": "queries_forwarded", + "DNS Unique Clients": "unique_clients", + "DNS Unique Domains": "unique_domains", + } + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + unique_id_parts = entity_entry.unique_id.split("/") + if len(unique_id_parts) == 2 and unique_id_parts[1] in name_to_key: + name = unique_id_parts[1] + new_unique_id = entity_entry.unique_id.replace(name, name_to_key[name]) + _LOGGER.debug("Migrate %s to %s", entity_entry.unique_id, new_unique_id) + return {"new_unique_id": new_unique_id} + + return None + + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) + session = async_get_clientsession(hass, verify_tls) api = Hole( host, diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 4aa391b567f..7ec1bf40c66 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -77,7 +77,7 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( ), PiHoleBinarySensorEntityDescription( key="status", - name="Status", + translation_key="status", icon="mdi:pi-hole", state_value=lambda api: bool(api.data.get("status") == "enabled"), ), @@ -109,6 +109,7 @@ class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): """Representation of a Pi-hole binary sensor.""" entity_description: PiHoleBinarySensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -121,12 +122,7 @@ class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): """Initialize a Pi-hole sensor.""" super().__init__(api, coordinator, name, server_unique_id) self.entity_description = description - - if description.key == "status": - self._attr_name = f"{name}" - else: - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.key}" @property def is_on(self) -> bool: diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 5d36ba67e83..c6a8d5da83d 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -17,55 +17,55 @@ from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="ads_blocked_today", - name="Ads Blocked Today", + translation_key="ads_blocked_today", native_unit_of_measurement="ads", icon="mdi:close-octagon-outline", ), SensorEntityDescription( key="ads_percentage_today", - name="Ads Percentage Blocked Today", + translation_key="ads_percentage_today", native_unit_of_measurement=PERCENTAGE, icon="mdi:close-octagon-outline", ), SensorEntityDescription( key="clients_ever_seen", - name="Seen Clients", + translation_key="clients_ever_seen", native_unit_of_measurement="clients", icon="mdi:account-outline", ), SensorEntityDescription( key="dns_queries_today", - name="DNS Queries Today", + translation_key="dns_queries_today", native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), SensorEntityDescription( key="domains_being_blocked", - name="Domains Blocked", + translation_key="domains_being_blocked", native_unit_of_measurement="domains", icon="mdi:block-helper", ), SensorEntityDescription( key="queries_cached", - name="DNS Queries Cached", + translation_key="queries_cached", native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), SensorEntityDescription( key="queries_forwarded", - name="DNS Queries Forwarded", + translation_key="queries_forwarded", native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), SensorEntityDescription( key="unique_clients", - name="DNS Unique Clients", + translation_key="unique_clients", native_unit_of_measurement="clients", icon="mdi:account-outline", ), SensorEntityDescription( key="unique_domains", - name="DNS Unique Domains", + translation_key="unique_domains", native_unit_of_measurement="domains", icon="mdi:domain", ), @@ -95,6 +95,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): """Representation of a Pi-hole sensor.""" entity_description: SensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -108,8 +109,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): super().__init__(api, coordinator, name, server_unique_id) self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.key}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 2f04b8fe47e..a997060eb58 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -32,5 +32,26 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "status": { "name": "Status" } + }, + "sensor": { + "ads_blocked_today": { "name": "Ads blocked today" }, + "ads_percentage_today": { "name": "Ads percentage blocked today" }, + "clients_ever_seen": { "name": "Seen clients" }, + "dns_queries_today": { "name": "DNS queries today" }, + "domains_being_blocked": { "name": "Domains blocked" }, + "queries_cached": { "name": "DNS queries cached" }, + "queries_forwarded": { "name": "DNS queries forwarded" }, + "unique_clients": { "name": "DNS unique clients" }, + "unique_domains": { "name": "DNS unique domains" } + }, + "update": { + "core_update_available": { "name": "Core update available" }, + "ftl_update_available": { "name": "FTL update available" }, + "web_update_available": { "name": "Web update available" } + } } } diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index d92d78dbb52..b9d8bf828d4 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -30,7 +30,7 @@ class PiHoleUpdateEntityDescription(UpdateEntityDescription): UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( PiHoleUpdateEntityDescription( key="core_update_available", - name="Core Update Available", + translation_key="core_update_available", title="Pi-hole Core", entity_category=EntityCategory.DIAGNOSTIC, installed_version=lambda versions: versions.get("core_current"), @@ -39,7 +39,7 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( ), PiHoleUpdateEntityDescription( key="web_update_available", - name="Web Update Available", + translation_key="web_update_available", title="Pi-hole Web interface", entity_category=EntityCategory.DIAGNOSTIC, installed_version=lambda versions: versions.get("web_current"), @@ -48,7 +48,7 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( ), PiHoleUpdateEntityDescription( key="ftl_update_available", - name="FTL Update Available", + translation_key="ftl_update_available", title="Pi-hole FTL DNS", entity_category=EntityCategory.DIAGNOSTIC, installed_version=lambda versions: versions.get("FTL_current"), @@ -81,6 +81,7 @@ class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): """Representation of a Pi-hole update entity.""" entity_description: PiHoleUpdateEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -94,8 +95,7 @@ class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): super().__init__(api, coordinator, name, server_unique_id) self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.key}" self._attr_title = description.title @property diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 35273e6fce0..e385156c6d1 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.recorder import get_instance, history from homeassistant.const import ( - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONDUCTIVITY, CONF_SENSORS, @@ -29,48 +28,44 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from .const import ( + ATTR_DICT_OF_UNITS_OF_MEASUREMENT, + ATTR_MAX_BRIGHTNESS_HISTORY, + ATTR_PROBLEM, + ATTR_SENSORS, + CONF_CHECK_DAYS, + CONF_MAX_BRIGHTNESS, + CONF_MAX_CONDUCTIVITY, + CONF_MAX_MOISTURE, + CONF_MAX_TEMPERATURE, + CONF_MIN_BATTERY_LEVEL, + CONF_MIN_BRIGHTNESS, + CONF_MIN_CONDUCTIVITY, + CONF_MIN_MOISTURE, + CONF_MIN_TEMPERATURE, + DEFAULT_CHECK_DAYS, + DEFAULT_MAX_CONDUCTIVITY, + DEFAULT_MAX_MOISTURE, + DEFAULT_MIN_BATTERY_LEVEL, + DEFAULT_MIN_CONDUCTIVITY, + DEFAULT_MIN_MOISTURE, + DOMAIN, + PROBLEM_NONE, + READING_BATTERY, + READING_BRIGHTNESS, + READING_CONDUCTIVITY, + READING_MOISTURE, + READING_TEMPERATURE, +) + _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "plant" - -READING_BATTERY = "battery" -READING_TEMPERATURE = ATTR_TEMPERATURE -READING_MOISTURE = "moisture" -READING_CONDUCTIVITY = "conductivity" -READING_BRIGHTNESS = "brightness" - -ATTR_PROBLEM = "problem" -ATTR_SENSORS = "sensors" -PROBLEM_NONE = "none" -ATTR_MAX_BRIGHTNESS_HISTORY = "max_brightness" - -# we're not returning only one value, we're returning a dict here. So we need -# to have a separate literal for it to avoid confusion. -ATTR_DICT_OF_UNITS_OF_MEASUREMENT = "unit_of_measurement_dict" - -CONF_MIN_BATTERY_LEVEL = f"min_{READING_BATTERY}" -CONF_MIN_TEMPERATURE = f"min_{READING_TEMPERATURE}" -CONF_MAX_TEMPERATURE = f"max_{READING_TEMPERATURE}" -CONF_MIN_MOISTURE = f"min_{READING_MOISTURE}" -CONF_MAX_MOISTURE = f"max_{READING_MOISTURE}" -CONF_MIN_CONDUCTIVITY = f"min_{READING_CONDUCTIVITY}" -CONF_MAX_CONDUCTIVITY = f"max_{READING_CONDUCTIVITY}" -CONF_MIN_BRIGHTNESS = f"min_{READING_BRIGHTNESS}" -CONF_MAX_BRIGHTNESS = f"max_{READING_BRIGHTNESS}" -CONF_CHECK_DAYS = "check_days" - CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY CONF_SENSOR_MOISTURE = READING_MOISTURE CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS -DEFAULT_MIN_BATTERY_LEVEL = 20 -DEFAULT_MIN_MOISTURE = 20 -DEFAULT_MAX_MOISTURE = 60 -DEFAULT_MIN_CONDUCTIVITY = 500 -DEFAULT_MAX_CONDUCTIVITY = 3000 -DEFAULT_CHECK_DAYS = 3 SCHEMA_SENSORS = vol.Schema( { @@ -104,8 +99,6 @@ PLANT_SCHEMA = vol.Schema( } ) -DOMAIN = "plant" - CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: PLANT_SCHEMA}}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/plant/const.py b/homeassistant/components/plant/const.py new file mode 100644 index 00000000000..0368c55e152 --- /dev/null +++ b/homeassistant/components/plant/const.py @@ -0,0 +1,37 @@ +"""Const for Plant.""" +from typing import Final + +DOMAIN: Final = "plant" + +READING_MOISTURE = "moisture" +READING_BATTERY = "battery" +READING_TEMPERATURE = "temperature" +READING_CONDUCTIVITY = "conductivity" +READING_BRIGHTNESS = "brightness" + +CONF_MIN_BATTERY_LEVEL = f"min_{READING_BATTERY}" +CONF_MIN_TEMPERATURE = f"min_{READING_TEMPERATURE}" +CONF_MAX_TEMPERATURE = f"max_{READING_TEMPERATURE}" +CONF_MIN_MOISTURE = f"min_{READING_MOISTURE}" +CONF_MAX_MOISTURE = f"max_{READING_MOISTURE}" +CONF_MIN_CONDUCTIVITY = f"min_{READING_CONDUCTIVITY}" +CONF_MAX_CONDUCTIVITY = f"max_{READING_CONDUCTIVITY}" +CONF_MIN_BRIGHTNESS = f"min_{READING_BRIGHTNESS}" +CONF_MAX_BRIGHTNESS = f"max_{READING_BRIGHTNESS}" +CONF_CHECK_DAYS = "check_days" + +DEFAULT_MIN_BATTERY_LEVEL = 20 +DEFAULT_MIN_MOISTURE = 20 +DEFAULT_MAX_MOISTURE = 60 +DEFAULT_MIN_CONDUCTIVITY = 500 +DEFAULT_MAX_CONDUCTIVITY = 3000 +DEFAULT_CHECK_DAYS = 3 + +ATTR_PROBLEM = "problem" +ATTR_SENSORS = "sensors" +PROBLEM_NONE = "none" +ATTR_MAX_BRIGHTNESS_HISTORY = "max_brightness" + +# we're not returning only one value, we're returning a dict here. So we need +# to have a separate literal for it to avoid confusion. +ATTR_DICT_OF_UNITS_OF_MEASUREMENT = "unit_of_measurement_dict" diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 8040e339727..3fce1509c0f 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -31,26 +31,27 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( PlugwiseBinarySensorEntityDescription( key="compressor_state", - name="Compressor state", + translation_key="compressor_state", icon="mdi:hvac", icon_off="mdi:hvac-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_enabled", - name="Cooling enabled", + translation_key="cooling_enabled", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="dhw_state", - name="DHW state", + translation_key="dhw_state", icon="mdi:water-pump", icon_off="mdi:water-pump-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="flame_state", + translation_key="flame_state", name="Flame state", icon="mdi:fire", icon_off="mdi:fire-off", @@ -58,28 +59,28 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( ), PlugwiseBinarySensorEntityDescription( key="heating_state", - name="Heating", + translation_key="heating_state", icon="mdi:radiator", icon_off="mdi:radiator-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_state", - name="Cooling", + translation_key="cooling_state", icon="mdi:snowflake", icon_off="mdi:snowflake-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="slave_boiler_state", - name="Secondary boiler state", + translation_key="slave_boiler_state", icon="mdi:fire", icon_off="mdi:circle-off-outline", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="plugwise_notification", - name="Plugwise notification", + translation_key="plugwise_notification", icon="mdi:mailbox-up-outline", icon_off="mdi:mailbox-outline", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 904b0de7a4d..5a3e394b119 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -43,9 +43,9 @@ class PlugwiseNumberEntityDescription( NUMBER_TYPES = ( PlugwiseNumberEntityDescription( key="maximum_boiler_temperature", + translation_key="maximum_boiler_temperature", command=lambda api, number, value: api.set_number_setpoint(number, value), device_class=NumberDeviceClass.TEMPERATURE, - name="Maximum boiler temperature setpoint", entity_category=EntityCategory.CONFIG, native_max_value_key="upper_bound", native_min_value_key="lower_bound", diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 67779386a3c..c45d47004b8 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -37,7 +37,7 @@ class PlugwiseSelectEntityDescription( SELECT_TYPES = ( PlugwiseSelectEntityDescription( key="select_schedule", - name="Thermostat schedule", + translation_key="select_schedule", icon="mdi:calendar-clock", command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON), current_option_key="selected_schedule", @@ -45,20 +45,18 @@ SELECT_TYPES = ( ), PlugwiseSelectEntityDescription( key="select_regulation_mode", - name="Regulation mode", + translation_key="regulation_mode", icon="mdi:hvac", entity_category=EntityCategory.CONFIG, - translation_key="regulation_mode", command=lambda api, loc, opt: api.set_regulation_mode(opt), current_option_key="regulation_mode", options_key="regulation_modes", ), PlugwiseSelectEntityDescription( key="select_dhw_mode", - name="DHW mode", + translation_key="dhw_mode", icon="mdi:shower", entity_category=EntityCategory.CONFIG, - translation_key="dhw_mode", command=lambda api, loc, opt: api.set_dhw_mode(opt), current_option_key="dhw_mode", options_key="dhw_modes", diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 7d9f32f2651..afc921f1101 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -26,38 +26,94 @@ } }, "entity": { + "binary_sensor": { + "compressor_state": { + "name": "Compressor state" + }, + "cooling_enabled": { + "name": "Cooling enabled" + }, + "dhw_state": { + "name": "DHW state" + }, + "flame_state": { + "name": "Flame state" + }, + "heating_state": { + "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]" + }, + "cooling_state": { + "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" + }, + "slave_boiler_state": { + "name": "Secondary boiler state" + }, + "plugwise_notification": { + "name": "Plugwise notification" + } + }, "climate": { "plugwise": { "state_attributes": { + "available_schemas": { + "name": "Available schemas" + }, "preset_mode": { "state": { "asleep": "Night", - "away": "Away", - "home": "Home", + "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" } + }, + "selected_schema": { + "name": "Selected schema" } } } }, + "number": { + "maximum_boiler_temperature": { + "name": "Maximum boiler temperature setpoint" + } + }, "select": { "dhw_mode": { + "name": "DHW mode", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", - "boost": "Boost", - "comfort": "Comfort" + "boost": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::boost%]", + "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]" } }, "regulation_mode": { + "name": "Regulation mode", "state": { "bleeding_cold": "Bleeding cold", "bleeding_hot": "Bleeding hot", - "cooling": "Cooling", - "heating": "Heating", - "off": "Off" + "cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]", + "heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]", + "off": "[%key:common::state::off%]" } + }, + "select_schedule": { + "name": "Thermostat schedule" + } + }, + "switch": { + "cooling_ena_switch": { + "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" + }, + "dhw_cm_switch": { + "name": "DHW comfort mode" + }, + "lock": { + "name": "[%key:component::lock::entity_component::_::name%]" + }, + "relay": { + "name": "Relay" } } } diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 2050de5fb98..d6f8a420cc8 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -21,19 +21,19 @@ from .util import plugwise_command SWITCHES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="dhw_cm_switch", - name="DHW comfort mode", + translation_key="dhw_cm_switch", icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key="lock", - name="Lock", + translation_key="lock", icon="mdi:lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key="relay", - name="Relay", + translation_key="relay", device_class=SwitchDeviceClass.SWITCH, ), SwitchEntityDescription( diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 3962ae4c060..c541e2cc0f2 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ICON = "mdi:rss" SENSOR_NAME = "Pocketcasts unlistened episodes" @@ -48,6 +47,8 @@ def setup_platform( class PocketCastsSensor(SensorEntity): """Representation of a pocket casts sensor.""" + _attr_icon = "mdi:rss" + def __init__(self, api): """Initialize the sensor.""" self._api = api @@ -63,11 +64,6 @@ class PocketCastsSensor(SensorEntity): """Return the sensor state.""" return self._state - @property - def icon(self): - """Return the icon for the sensor.""" - return ICON - def update(self) -> None: """Update sensor values.""" try: diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 27e302f47c4..f558b7301c5 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval @@ -29,6 +29,8 @@ SERVICE_START = "start" SERVICE_MEMORY = "memory" SERVICE_START_LOG_OBJECTS = "start_log_objects" SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" +SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" +SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" @@ -60,7 +62,10 @@ SERVICES = ( DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +DEFAULT_MAX_OBJECTS = 5 + CONF_SECONDS = "seconds" +CONF_MAX_OBJECTS = "max_objects" LOG_INTERVAL_SUB = "log_interval_subscription" @@ -85,7 +90,7 @@ async def async_setup_entry( # noqa: C901 async def _async_start_log_objects(call: ServiceCall) -> None: if LOG_INTERVAL_SUB in domain_data: - domain_data[LOG_INTERVAL_SUB]() + raise HomeAssistantError("Object logging already started") persistent_notification.async_create( hass, @@ -103,21 +108,53 @@ async def async_setup_entry( # noqa: C901 async def _async_stop_log_objects(call: ServiceCall) -> None: if LOG_INTERVAL_SUB not in domain_data: - return + raise HomeAssistantError("Object logging not running") persistent_notification.async_dismiss(hass, "profile_object_logging") domain_data.pop(LOG_INTERVAL_SUB)() - def _safe_repr(obj: Any) -> str: - """Get the repr of an object but keep going if there is an exception. + async def _async_start_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") - We wrap repr to ensure if one object cannot be serialized, we can - still get the rest. - """ - try: - return repr(obj) - except Exception: # pylint: disable=broad-except - return f"Failed to serialize {type(obj)}" + persistent_notification.async_create( + hass, + ( + "Object source logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object source logging started", + notification_id="profile_object_source_logging", + ) + + last_ids: set[int] = set() + last_stats: dict[str, int] = {} + + async def _log_object_sources_with_max(*_: Any) -> None: + await hass.async_add_executor_job( + _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats + ) + + await _log_object_sources_with_max() + cancel_track = async_track_time_interval( + hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] + ) + + @callback + def _cancel(): + cancel_track() + last_ids.clear() + last_stats.clear() + + domain_data[LOG_INTERVAL_SUB] = _cancel + + @callback + def _async_stop_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_source_logging") + domain_data.pop(LOG_INTERVAL_SUB)() def _dump_log_objects(call: ServiceCall) -> None: # Imports deferred to avoid loading modules @@ -127,11 +164,12 @@ async def async_setup_entry( # noqa: C901 obj_type = call.data[CONF_TYPE] - _LOGGER.critical( - "%s objects in memory: %s", - obj_type, - [_safe_repr(obj) for obj in objgraph.by_type(obj_type)], - ) + for obj in objgraph.by_type(obj_type): + _LOGGER.critical( + "%s object in memory: %s", + obj_type, + _safe_repr(obj), + ) persistent_notification.create( hass, @@ -143,15 +181,6 @@ async def async_setup_entry( # noqa: C901 notification_id="profile_object_dump", ) - def _get_function_absfile(func: Any) -> str: - """Get the absolute file path of a function.""" - import inspect # pylint: disable=import-outside-toplevel - - abs_file = "unknown" - with suppress(Exception): - abs_file = inspect.getabsfile(func) - return abs_file - def _lru_stats(call: ServiceCall) -> None: """Log the stats of all lru caches.""" # Imports deferred to avoid loading modules @@ -164,7 +193,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.critical( "Cache stats for lru_cache %s at %s: %s", lru.__wrapped__, - _get_function_absfile(lru.__wrapped__), + _get_function_absfile(lru.__wrapped__) or "unknown", lru.cache_info(), ) @@ -175,7 +204,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.critical( "Cache stats for LRU %s at %s: %s", type(class_with_lru_attr), - _get_function_absfile(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr) or "unknown", maybe_lru.get_stats(), ) @@ -267,6 +296,30 @@ async def async_setup_entry( # noqa: C901 _async_stop_log_objects, ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + _async_start_object_sources, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( + min=1, max=1024 + ), + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECT_SOURCES, + _async_stop_object_sources, + ) + async_register_admin_service( hass, DOMAIN, @@ -404,3 +457,101 @@ def _log_objects(*_): import objgraph # pylint: disable=import-outside-toplevel _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) + + +def _get_function_absfile(func: Any) -> str | None: + """Get the absolute file path of a function.""" + import inspect # pylint: disable=import-outside-toplevel + + abs_file: str | None = None + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + +def _safe_repr(obj: Any) -> str: + """Get the repr of an object but keep going if there is an exception. + + We wrap repr to ensure if one object cannot be serialized, we can + still get the rest. + """ + try: + return repr(obj) + except Exception: # pylint: disable=broad-except + return f"Failed to serialize {type(obj)}" + + +def _find_backrefs_not_to_self(_object: Any) -> list[str]: + import objgraph # pylint: disable=import-outside-toplevel + + return [ + _safe_repr(backref) + for backref in objgraph.find_backref_chain( + _object, lambda obj: obj is not _object + ) + ] + + +def _log_object_sources( + max_objects: int, last_ids: set[int], last_stats: dict[str, int] +) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import gc # pylint: disable=import-outside-toplevel + + gc.collect() + + objects = gc.get_objects() + new_objects: list[object] = [] + new_objects_overflow: dict[str, int] = {} + current_ids = set() + new_stats: dict[str, int] = {} + had_new_object_growth = False + try: + for _object in objects: + object_type = type(_object).__name__ + new_stats[object_type] = new_stats.get(object_type, 0) + 1 + + for _object in objects: + id_ = id(_object) + current_ids.add(id_) + if id_ in last_ids: + continue + object_type = type(_object).__name__ + if last_stats.get(object_type, 0) < new_stats[object_type]: + if len(new_objects) < max_objects: + new_objects.append(_object) + else: + new_objects_overflow.setdefault(object_type, 0) + new_objects_overflow[object_type] += 1 + + for _object in new_objects: + had_new_object_growth = True + object_type = type(_object).__name__ + _LOGGER.critical( + "New object %s (%s/%s) at %s: %s", + object_type, + last_stats.get(object_type, 0), + new_stats[object_type], + _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), + _safe_repr(_object), + ) + + for object_type, count in last_stats.items(): + new_stats[object_type] = max(new_stats.get(object_type, 0), count) + finally: + # Break reference cycles + del objects + del new_objects + last_ids.clear() + last_ids.update(current_ids) + last_stats.clear() + last_stats.update(new_stats) + del new_stats + del current_ids + + if new_objects_overflow: + _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) + elif not had_new_object_growth: + _LOGGER.critical("No new object growth found") diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 1105842891f..3bd6d7636ac 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -25,7 +25,7 @@ memory: max: 3600 unit_of_measurement: seconds start_log_objects: - name: Start log objects + name: Start logging objects description: Start logging growth of objects in memory fields: scan_interval: @@ -38,7 +38,7 @@ start_log_objects: max: 3600 unit_of_measurement: seconds stop_log_objects: - name: Stop log objects + name: Stop logging objects description: Stop logging growth of objects in memory. dump_log_objects: name: Dump log objects @@ -51,6 +51,31 @@ dump_log_objects: example: State selector: text: +start_log_object_sources: + name: Start logging object sources + description: Start logging sources of new objects in memory + fields: + scan_interval: + name: Scan interval + description: The number of seconds between logging objects. + default: 30.0 + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds + max_objects: + name: Maximum objects + description: The maximum number of objects to log. + default: 5 + selector: + number: + min: 1 + max: 30 + unit_of_measurement: objects +stop_log_object_sources: + name: Stop logging object sources + description: Stop logging sources of new objects in memory. lru_stats: name: Log LRU stats description: Log the stats of all lru caches. diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index b61f0ca4df5..1c22ca50c23 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -134,7 +134,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(build_client) - coordinators: dict[str, dict[str, dict[int, DataUpdateCoordinator]]] = {} + coordinators: dict[ + str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]] + ] = {} hass.data[DOMAIN][COORDINATORS] = coordinators # Create a coordinator for each vm/container diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index f2dd939122b..7e95b209bad 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -38,7 +38,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { "printer": ( PrusaLinkButtonEntityDescription[PrinterInfo]( key="printer.cancel_job", - name="Cancel Job", + translation_key="cancel_job", icon="mdi:cancel", press_fn=lambda api: cast(Coroutine, api.cancel_job()), available_fn=lambda data: any( @@ -48,7 +48,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { ), PrusaLinkButtonEntityDescription[PrinterInfo]( key="job.pause_job", - name="Pause Job", + translation_key="pause_job", icon="mdi:pause", press_fn=lambda api: cast(Coroutine, api.pause_job()), available_fn=lambda data: ( @@ -58,7 +58,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { ), PrusaLinkButtonEntityDescription[PrinterInfo]( key="job.resume_job", - name="Resume Job", + translation_key="resume_job", icon="mdi:play", press_fn=lambda api: cast(Coroutine, api.resume_job()), available_fn=lambda data: cast(bool, data["state"]["flags"]["paused"]), diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index a6c16e2f5f2..a8b8f387eff 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -24,7 +24,7 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): last_path = "" last_image: bytes - _attr_name = "Job Preview" + _attr_translation_key = "job_preview" def __init__(self, coordinator: JobUpdateCoordinator) -> None: """Initialize a PrusaLink camera entity.""" diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index 6b0e6189f41..cef2bdf2f6e 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -12,6 +12,7 @@ from pyprusalink import InvalidAuth, PrusaLink import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -24,8 +25,8 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("api_key"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_KEY): str, } ) @@ -35,7 +36,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = PrusaLink(async_get_clientsession(hass), data["host"], data["api_key"]) + api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) try: async with async_timeout.timeout(5): @@ -51,7 +52,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, except AwesomeVersionException as err: raise NotSupported from err - return {"title": version["hostname"]} + return {"title": version["hostname"] or version["text"]} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -68,13 +69,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - host = user_input["host"].rstrip("/") + host = user_input[CONF_HOST].rstrip("/") if not host.startswith(("http://", "https://")): host = f"http://{host}" data = { - "host": host, - "api_key": user_input["api_key"], + CONF_HOST: host, + CONF_API_KEY: user_input[CONF_API_KEY], } errors = {} diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 9edd09f0ff1..bcfadb29166 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -65,7 +65,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[PrinterInfo]( key="printer.telemetry.temp-bed", - name="Heatbed", + translation_key="heatbed_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -74,7 +74,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[PrinterInfo]( key="printer.telemetry.temp-nozzle", - name="Nozzle Temperature", + translation_key="nozzle_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -85,7 +85,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { "job": ( PrusaLinkSensorEntityDescription[JobInfo]( key="job.progress", - name="Progress", + translation_key="progress", icon="mdi:progress-clock", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: cast(float, data["progress"]["completion"]) * 100, @@ -93,14 +93,14 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", - name="Filename", + translation_key="filename", icon="mdi:file-image-outline", value_fn=lambda data: cast(str, data["job"]["file"]["display"]), available_fn=lambda data: data.get("job") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.start", - name="Print Start", + translation_key="print_start", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock-start", value_fn=ignore_variance( @@ -113,7 +113,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.finish", - name="Print Finish", + translation_key="print_finish", icon="mdi:clock-end", device_class=SensorDeviceClass.TIMESTAMP, value_fn=ignore_variance( diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 9d6a1a2ed53..34611e4fffb 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -25,6 +25,40 @@ "pausing": "Pausing", "printing": "Printing" } + }, + "heatbed_temperature": { + "name": "Heatbed temperature" + }, + "nozzle_temperature": { + "name": "Nozzle temperature" + }, + "progress": { + "name": "Progress" + }, + "filename": { + "name": "Filename" + }, + "print_start": { + "name": "Print start" + }, + "print_finish": { + "name": "Print finish" + } + }, + "button": { + "cancel_job": { + "name": "Cancel job" + }, + "pause_job": { + "name": "Pause job" + }, + "resume_job": { + "name": "Resume job" + } + }, + "camera": { + "job_preview": { + "name": "Preview" } } } diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 19cf403eab2..d4db30fd61e 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Random Sensor" DEFAULT_MIN = 0 DEFAULT_MAX = 20 -ICON = "mdi:hanger" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -54,6 +53,8 @@ async def async_setup_platform( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_icon = "mdi:hanger" + def __init__(self, name, minimum, maximum, unit_of_measurement): """Initialize the Random sensor.""" self._name = name @@ -72,11 +73,6 @@ class RandomSensor(SensorEntity): """Return the state of the device.""" return self._state - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index fbc929b17d1..8969b7a27e3 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -58,6 +58,7 @@ from .const import ( SupportedDialect, ) from .db_schema import ( + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX, LEGACY_STATES_EVENT_ID_INDEX, SCHEMA_VERSION, TABLE_STATES, @@ -96,6 +97,7 @@ from .tasks import ( CompileMissingStatisticsTask, DatabaseLockTask, EntityIDMigrationTask, + EntityIDPostMigrationTask, EventIdMigrationTask, EventsContextIDMigrationTask, EventTask, @@ -299,7 +301,7 @@ class Recorder(threading.Thread): self.hass, self._async_check_queue, timedelta(minutes=10), - "Recorder queue watcher", + name="Recorder queue watcher", ) @callback @@ -602,7 +604,7 @@ class Recorder(threading.Thread): self.hass, self._async_keep_alive, timedelta(seconds=KEEPALIVE_TIME), - "Recorder keep alive", + name="Recorder keep alive", ) # If the commit interval is not 0, we need to commit periodically @@ -611,7 +613,7 @@ class Recorder(threading.Thread): self.hass, self._async_commit, timedelta(seconds=self.commit_interval), - "Recorder commit", + name="Recorder commit", ) # Run nightly tasks at 4:12am @@ -757,6 +759,18 @@ class Recorder(threading.Thread): else: _LOGGER.debug("Activating states_meta manager as all data is migrated") self.states_meta_manager.active = True + with contextlib.suppress(SQLAlchemyError): + # If ix_states_entity_id_last_updated_ts still exists + # on the states table it means the entity id migration + # finished by the EntityIDPostMigrationTask did not + # because they restarted in the middle of it. We need + # to pick back up where we left off. + if get_index_by_name( + session, + TABLE_STATES, + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX, + ): + self.queue_task(EntityIDPostMigrationTask()) if self.schema_version > LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION: with contextlib.suppress(SQLAlchemyError): diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index cf4c0543c12..c24886e776d 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -3,13 +3,12 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -from functools import lru_cache import logging import time from typing import Any, cast import ciso8601 -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from sqlalchemy import ( JSON, BigInteger, @@ -88,6 +87,8 @@ TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" STATISTICS_TABLES = ("statistics", "statistics_short_term") MAX_STATE_ATTRS_BYTES = 16384 +MAX_EVENT_DATA_BYTES = 32768 + PSQL_DIALECT = SupportedDialect.POSTGRESQL ALL_TABLES = [ @@ -117,6 +118,7 @@ METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" +LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated_ts" CONTEXT_ID_BIN_MAX_LENGTH = 16 MYSQL_COLLATE = "utf8mb4_unicode_ci" @@ -282,7 +284,7 @@ class Events(Base): """Convert to a native HA Event.""" context = Context( id=bytes_to_ulid_or_none(self.context_id_bin), - user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin), parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: @@ -327,14 +329,23 @@ class EventData(Base): ) -> bytes: """Create shared_data from an event.""" if dialect == SupportedDialect.POSTGRESQL: - return json_bytes_strip_null(event.data) - return json_bytes(event.data) + bytes_result = json_bytes_strip_null(event.data) + bytes_result = json_bytes(event.data) + if len(bytes_result) > MAX_EVENT_DATA_BYTES: + _LOGGER.warning( + "Event data for %s exceed maximum size of %s bytes. " + "This can cause database performance issues; Event data " + "will not be stored", + event.event_type, + MAX_EVENT_DATA_BYTES, + ) + return b"{}" + return bytes_result @staticmethod - @lru_cache def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data_bytes)) + return fnv1a_32(shared_data_bytes) def to_native(self) -> dict[str, Any]: """Convert to an event data dictionary.""" @@ -496,7 +507,7 @@ class States(Base): """Convert to an HA state object.""" context = Context( id=bytes_to_ulid_or_none(self.context_id_bin), - user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin), parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: @@ -580,10 +591,9 @@ class StateAttributes(Base): return bytes_result @staticmethod - @lru_cache(maxsize=2048) def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: """Return the hash of json encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs_bytes)) + return fnv1a_32(shared_attrs_bytes) def to_native(self) -> dict[str, Any]: """Convert to a state attributes dictionary.""" diff --git a/homeassistant/components/recorder/history/const.py b/homeassistant/components/recorder/history/const.py index 33717ca78cf..61a615a7979 100644 --- a/homeassistant/components/recorder/history/const.py +++ b/homeassistant/components/recorder/history/const.py @@ -13,7 +13,6 @@ SIGNIFICANT_DOMAINS = { } SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] IGNORE_DOMAINS = {"zone", "scene"} -IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] NEED_ATTRIBUTE_DOMAINS = { "climate", "humidifier", diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index c33825a767c..e9a435f9624 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -5,7 +5,6 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping from datetime import datetime from itertools import groupby -import logging from operator import attrgetter import time from typing import Any, cast @@ -13,7 +12,6 @@ from typing import Any, cast from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select from sqlalchemy.engine.row import Row from sqlalchemy.orm.properties import MappedColumn -from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal from sqlalchemy.sql.lambdas import StatementLambdaElement @@ -36,7 +34,6 @@ from ..models.legacy import LazyStatePreSchema31, row_to_compressed_state_pre_sc from ..util import execute_stmt_lambda_element, session_scope from .common import _schema_version from .const import ( - IGNORE_DOMAINS_ENTITY_ID_LIKE, LAST_CHANGED_KEY, NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS, @@ -44,9 +41,6 @@ from .const import ( STATE_KEY, ) -_LOGGER = logging.getLogger(__name__) - - _BASE_STATES = ( States.entity_id, States.state, @@ -229,24 +223,11 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Query) -> Query: - """Add a filter to ignore domains we do not fetch history for.""" - return query.filter( - and_( - *[ - ~States.entity_id.like(entity_domain) - for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE - ] - ) - ) - - def _significant_states_stmt( schema_version: int, start_time: datetime, end_time: datetime | None, - entity_ids: list[str] | None, - filters: Filters | None, + entity_ids: list[str], significant_changes_only: bool, no_attributes: bool, ) -> StatementLambdaElement: @@ -255,8 +236,7 @@ def _significant_states_stmt( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( - entity_ids - and len(entity_ids) == 1 + len(entity_ids) == 1 and significant_changes_only and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS ): @@ -297,18 +277,7 @@ def _significant_states_stmt( ), ) ) - - if entity_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - States.entity_id.in_(entity_ids) # type:ignore[arg-type] - ) - else: - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] - ) + stmt += lambda q: q.filter(States.entity_id.in_(entity_ids)) if schema_version >= 31: start_time_ts = start_time.timestamp() @@ -356,25 +325,25 @@ def get_significant_states_with_session( as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ + if filters is not None: + raise NotImplementedError("Filters are no longer supported") + if not entity_ids: + raise ValueError("entity_ids must be provided") stmt = _significant_states_stmt( _schema_version(hass), start_time, end_time, entity_ids, - filters, significant_changes_only, no_attributes, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_ids else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return _sorted_states_to_dict( hass, session, states, start_time, entity_ids, - filters, include_start_time_state, minimal_response, no_attributes, @@ -419,7 +388,7 @@ def _state_changed_during_period_stmt( schema_version: int, start_time: datetime, end_time: datetime | None, - entity_id: str | None, + entity_id: str, no_attributes: bool, descending: bool, limit: int | None, @@ -450,8 +419,7 @@ def _state_changed_during_period_stmt( stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) else: stmt += lambda q: q.filter(States.last_updated < end_time) - if entity_id: - stmt += lambda q: q.filter(States.entity_id == entity_id) + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -484,9 +452,9 @@ def state_changes_during_period( include_start_time_state: bool = True, ) -> MutableMapping[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" - entity_id = entity_id.lower() if entity_id is not None else None - entity_ids = [entity_id] if entity_id is not None else None - + if not entity_id: + raise ValueError("entity_id must be provided") + entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: stmt = _state_changed_during_period_stmt( _schema_version(hass), @@ -497,9 +465,7 @@ def state_changes_during_period( descending, limit, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_id else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -647,93 +613,17 @@ def _get_states_for_entities_stmt( return stmt -def _get_states_for_all_stmt( - schema_version: int, - run_start: datetime, - utc_point_in_time: datetime, - filters: Filters | None, - no_attributes: bool, -) -> StatementLambdaElement: - """Baked query to get states for all entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, no_attributes, include_last_changed=True - ) - # We did not get an include-list of entities, query all states in the inner - # query, then filter out unwanted domains as well as applying the custom filter. - # This filtering can't be done in the inner query because the domain column is - # not indexed and we can't control what's in the custom filter. - if schema_version >= 31: - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .group_by(States.entity_id) - .subquery() - ) - ), - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, - ), - ) - else: - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated).label("max_last_updated"), - ) - .filter( - (States.last_updated >= run_start) - & (States.last_updated < utc_point_in_time) - ) - .group_by(States.entity_id) - .subquery() - ) - ), - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated == most_recent_states_by_date.c.max_last_updated, - ), - ) - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] - ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - return stmt - - def _get_rows_with_session( hass: HomeAssistant, session: Session, utc_point_in_time: datetime, - entity_ids: list[str] | None = None, + entity_ids: list[str], run: RecorderRuns | None = None, - filters: Filters | None = None, no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" schema_version = _schema_version(hass) - if entity_ids and len(entity_ids) == 1: + if len(entity_ids) == 1: return execute_stmt_lambda_element( session, _get_single_entity_states_stmt( @@ -750,15 +640,9 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - if entity_ids: - stmt = _get_states_for_entities_stmt( - schema_version, run.start, utc_point_in_time, entity_ids, no_attributes - ) - else: - stmt = _get_states_for_all_stmt( - schema_version, run.start, utc_point_in_time, filters, no_attributes - ) - + stmt = _get_states_for_entities_stmt( + schema_version, run.start, utc_point_in_time, entity_ids, no_attributes + ) return execute_stmt_lambda_element(session, stmt) @@ -804,8 +688,7 @@ def _sorted_states_to_dict( session: Session, states: Iterable[Row], start_time: datetime, - entity_ids: list[str] | None, - filters: Filters | None = None, + entity_ids: list[str], include_start_time_state: bool = True, minimal_response: bool = False, no_attributes: bool = False, @@ -847,12 +730,11 @@ def _sorted_states_to_dict( result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) # Set all entity IDs to empty lists in result set to maintain the order - if entity_ids is not None: - for ent_id in entity_ids: - result[ent_id] = [] + for ent_id in entity_ids: + result[ent_id] = [] # Get the states at the start time - timer_start = time.perf_counter() + time.perf_counter() initial_states: dict[str, Row] = {} if include_start_time_state: initial_states = { @@ -862,16 +744,11 @@ def _sorted_states_to_dict( session, start_time, entity_ids, - filters=filters, no_attributes=no_attributes, ) } - if _LOGGER.isEnabledFor(logging.DEBUG): - elapsed = time.perf_counter() - timer_start - _LOGGER.debug("getting %d first datapoints took %fs", len(result), elapsed) - - if entity_ids and len(entity_ids) == 1: + if len(entity_ids) == 1: states_iter: Iterable[tuple[str, Iterator[Row]]] = ( (entity_ids[0], iter(states)), ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index f7d08c6bba8..75a99c6e502 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -8,10 +8,9 @@ from itertools import groupby from operator import itemgetter from typing import Any, cast -from sqlalchemy import Column, and_, func, lambda_stmt, or_, select +from sqlalchemy import Column, and_, func, lambda_stmt, select from sqlalchemy.engine.row import Row from sqlalchemy.orm.properties import MappedColumn -from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal from sqlalchemy.sql.lambdas import StatementLambdaElement @@ -21,7 +20,7 @@ from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util from ... import recorder -from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta +from ..db_schema import RecorderRuns, StateAttributes, States from ..filters import Filters from ..models import ( LazyState, @@ -31,11 +30,9 @@ from ..models import ( ) from ..util import execute_stmt_lambda_element, session_scope from .const import ( - IGNORE_DOMAINS_ENTITY_ID_LIKE, LAST_CHANGED_KEY, NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS, - SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, STATE_KEY, ) @@ -73,7 +70,7 @@ _FIELD_MAP = { def _lambda_stmt_and_join_attributes( no_attributes: bool, include_last_changed: bool = True -) -> tuple[StatementLambdaElement, bool]: +) -> StatementLambdaElement: """Return the lambda_stmt and if StateAttributes should be joined. Because these are lambda_stmt the values inside the lambdas need @@ -84,18 +81,12 @@ def _lambda_stmt_and_join_attributes( # state_attributes table if no_attributes: if include_last_changed: - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), - False, - ) - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), - False, - ) + return lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)) + return lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)) if include_last_changed: - return lambda_stmt(lambda: select(*_QUERY_STATES)), True - return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True + return lambda_stmt(lambda: select(*_QUERY_STATES)) + return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)) def get_significant_states( @@ -127,33 +118,19 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Query) -> Query: - """Add a filter to ignore domains we do not fetch history for.""" - return query.filter( - and_( - *[ - ~StatesMeta.entity_id.like(entity_domain) - for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE - ] - ) - ) - - def _significant_states_stmt( start_time: datetime, end_time: datetime | None, - metadata_ids: list[int] | None, + metadata_ids: list[int], metadata_ids_in_significant_domains: list[int], - filters: Filters | None, significant_changes_only: bool, no_attributes: bool, ) -> StatementLambdaElement: """Query the database for significant state changes.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( + stmt = _lambda_stmt_and_join_attributes( no_attributes, include_last_changed=not significant_changes_only ) - join_states_meta = False - if metadata_ids and significant_changes_only: + if significant_changes_only: # Since we are filtering on entity_id (metadata_id) we can avoid # the join of the states_meta table since we already know which # metadata_ids are in the significant domains. @@ -162,52 +139,13 @@ def _significant_states_stmt( | (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) - elif significant_changes_only: - # This is the case where we are not filtering on entity_id - # so we need to join the states_meta table to filter out - # the domains we do not care about. This query path was - # only used by the old history page to show all entities - # in the UI. The new history page filters on entity_id - # so this query path is not used anymore except for third - # party integrations that use the history API. - stmt += lambda q: q.filter( - or_( - *[ - StatesMeta.entity_id.like(entity_domain) - for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE - ], - ( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ), - ) - ) - join_states_meta = True - - if metadata_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - States.metadata_id.in_(metadata_ids) # type:ignore[arg-type] - ) - else: - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] - track_on=[filters], - ) - join_states_meta = True - + stmt += lambda q: q.filter(States.metadata_id.in_(metadata_ids)) start_time_ts = start_time.timestamp() stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) if end_time: end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) - if join_states_meta: - stmt += lambda q: q.outerjoin( - StatesMeta, States.metadata_id == StatesMeta.metadata_id - ) - if join_attributes: + if not no_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) @@ -239,36 +177,36 @@ def get_significant_states_with_session( as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ + if filters is not None: + raise NotImplementedError("Filters are no longer supported") + if not entity_ids: + raise ValueError("entity_ids must be provided") metadata_ids: list[int] | None = None entity_id_to_metadata_id: dict[str, int | None] | None = None metadata_ids_in_significant_domains: list[int] = [] - if entity_ids: - instance = recorder.get_instance(hass) - if not ( - entity_id_to_metadata_id := instance.states_meta_manager.get_many( - entity_ids, session, False - ) - ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): - return {} - if significant_changes_only: - metadata_ids_in_significant_domains = [ - metadata_id - for entity_id, metadata_id in entity_id_to_metadata_id.items() - if metadata_id is not None - and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS - ] + instance = recorder.get_instance(hass) + if not ( + entity_id_to_metadata_id := instance.states_meta_manager.get_many( + entity_ids, session, False + ) + ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): + return {} + if significant_changes_only: + metadata_ids_in_significant_domains = [ + metadata_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is not None + and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS + ] stmt = _significant_states_stmt( start_time, end_time, metadata_ids, metadata_ids_in_significant_domains, - filters, significant_changes_only, no_attributes, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_ids else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return _sorted_states_to_dict( hass, session, @@ -276,7 +214,6 @@ def get_significant_states_with_session( start_time, entity_ids, entity_id_to_metadata_id, - filters, include_start_time_state, minimal_response, no_attributes, @@ -325,9 +262,7 @@ def _state_changed_during_period_stmt( descending: bool, limit: int | None, ) -> StatementLambdaElement: - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=False - ) + stmt = _lambda_stmt_and_join_attributes(no_attributes, include_last_changed=False) start_time_ts = start_time.timestamp() stmt += lambda q: q.filter( ( @@ -341,7 +276,7 @@ def _state_changed_during_period_stmt( stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) if metadata_id: stmt += lambda q: q.filter(States.metadata_id == metadata_id) - if join_attributes: + if not no_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) @@ -365,16 +300,18 @@ def state_changes_during_period( include_start_time_state: bool = True, ) -> MutableMapping[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" - entity_id = entity_id.lower() if entity_id is not None else None - entity_ids = [entity_id] if entity_id is not None else None + if not entity_id: + raise ValueError("entity_id must be provided") + entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: metadata_id: int | None = None - entity_id_to_metadata_id = None - if entity_id: - instance = recorder.get_instance(hass) - metadata_id = instance.states_meta_manager.get(entity_id, session, False) - entity_id_to_metadata_id = {entity_id: metadata_id} + instance = recorder.get_instance(hass) + if not ( + metadata_id := instance.states_meta_manager.get(entity_id, session, False) + ): + return {} + entity_id_to_metadata_id: dict[str, int | None] = {entity_id: metadata_id} stmt = _state_changed_during_period_stmt( start_time, end_time, @@ -383,9 +320,7 @@ def state_changes_during_period( descending, limit, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_id else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -403,9 +338,7 @@ def state_changes_during_period( def _get_last_state_changes_stmt( number_of_states: int, metadata_id: int ) -> StatementLambdaElement: - stmt, join_attributes = _lambda_stmt_and_join_attributes( - False, include_last_changed=False - ) + stmt = _lambda_stmt_and_join_attributes(False, include_last_changed=False) if number_of_states == 1: stmt += lambda q: q.join( ( @@ -438,12 +371,9 @@ def _get_last_state_changes_stmt( .subquery() ).c.state_id ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ) - - stmt += lambda q: q.order_by(States.state_id.desc()) + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ).order_by(States.state_id.desc()) return stmt @@ -488,9 +418,7 @@ def _get_states_for_entities_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Baked query to get states for specific entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) + stmt = _lambda_stmt_and_join_attributes(no_attributes, include_last_changed=True) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. run_start_ts = process_timestamp(run_start).timestamp() @@ -520,79 +448,24 @@ def _get_states_for_entities_stmt( == most_recent_states_for_entities_by_date.c.max_last_updated, ), ) - if join_attributes: + if not no_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) return stmt -def _get_states_for_all_stmt( - run_start: datetime, - utc_point_in_time: datetime, - filters: Filters | None, - no_attributes: bool, -) -> StatementLambdaElement: - """Baked query to get states for all entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) - # We did not get an include-list of entities, query all states in the inner - # query, then filter out unwanted domains as well as applying the custom filter. - # This filtering can't be done in the inner query because the domain column is - # not indexed and we can't control what's in the custom filter. - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.metadata_id.label("max_metadata_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .group_by(States.metadata_id) - .subquery() - ) - ), - and_( - States.metadata_id == most_recent_states_by_date.c.max_metadata_id, - States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, - ), - ) - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] - track_on=[filters], - ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - stmt += lambda q: q.outerjoin( - StatesMeta, States.metadata_id == StatesMeta.metadata_id - ) - return stmt - - def _get_rows_with_session( hass: HomeAssistant, session: Session, utc_point_in_time: datetime, - entity_ids: list[str] | None = None, + entity_ids: list[str], entity_id_to_metadata_id: dict[str, int | None] | None = None, run: RecorderRuns | None = None, - filters: Filters | None = None, no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" - if entity_ids and len(entity_ids) == 1: + if len(entity_ids) == 1: if not entity_id_to_metadata_id or not ( metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) ): @@ -613,19 +486,13 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - if entity_ids: - if not entity_id_to_metadata_id or not ( - metadata_ids := extract_metadata_ids(entity_id_to_metadata_id) - ): - return [] - stmt = _get_states_for_entities_stmt( - run.start, utc_point_in_time, metadata_ids, no_attributes - ) - else: - stmt = _get_states_for_all_stmt( - run.start, utc_point_in_time, filters, no_attributes - ) - + if not entity_id_to_metadata_id or not ( + metadata_ids := extract_metadata_ids(entity_id_to_metadata_id) + ): + return [] + stmt = _get_states_for_entities_stmt( + run.start, utc_point_in_time, metadata_ids, no_attributes + ) return execute_stmt_lambda_element(session, stmt) @@ -636,9 +503,7 @@ def _get_single_entity_states_stmt( ) -> StatementLambdaElement: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) + stmt = _lambda_stmt_and_join_attributes(no_attributes, include_last_changed=True) utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) stmt += ( lambda q: q.filter( @@ -648,7 +513,7 @@ def _get_single_entity_states_stmt( .order_by(States.last_updated_ts.desc()) .limit(1) ) - if join_attributes: + if not no_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) @@ -660,9 +525,8 @@ def _sorted_states_to_dict( session: Session, states: Iterable[Row], start_time: datetime, - entity_ids: list[str] | None, - entity_id_to_metadata_id: dict[str, int | None] | None, - filters: Filters | None = None, + entity_ids: list[str], + entity_id_to_metadata_id: dict[str, int | None], include_start_time_state: bool = True, minimal_response: bool = False, no_attributes: bool = False, @@ -697,19 +561,12 @@ def _sorted_states_to_dict( metadata_id_idx = field_map["metadata_id"] # Set all entity IDs to empty lists in result set to maintain the order - if entity_ids is not None: - for ent_id in entity_ids: - result[ent_id] = [] - - if entity_id_to_metadata_id: - metadata_id_to_entity_id = { - v: k for k, v in entity_id_to_metadata_id.items() if v is not None - } - else: - metadata_id_to_entity_id = recorder.get_instance( - hass - ).states_meta_manager.get_metadata_id_to_entity_id(session) + for ent_id in entity_ids: + result[ent_id] = [] + metadata_id_to_entity_id = { + v: k for k, v in entity_id_to_metadata_id.items() if v is not None + } # Get the states at the start time initial_states: dict[int, Row] = {} if include_start_time_state: @@ -721,16 +578,13 @@ def _sorted_states_to_dict( start_time, entity_ids, entity_id_to_metadata_id, - filters=filters, no_attributes=no_attributes, ) } - if entity_ids and len(entity_ids) == 1: - if not entity_id_to_metadata_id or not ( - metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) - ): - return {} + if len(entity_ids) == 1: + metadata_id = entity_id_to_metadata_id[entity_ids[0]] + assert metadata_id is not None # should not be possible if we got here states_iter: Iterable[tuple[int, Iterator[Row]]] = ( (metadata_id, iter(states)), ) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index c64c38fb7e5..97eb9d062b0 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.7", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.8", "fnv-hash-fast==0.3.1"] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4be01327654..4b0244038e8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -48,6 +48,7 @@ from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, DOUBLE_PRECISION_TYPE_SQL, + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX, LEGACY_STATES_EVENT_ID_INDEX, MYSQL_COLLATE, MYSQL_DEFAULT_CHARSET, @@ -913,7 +914,7 @@ def _apply_update( # noqa: C901 _create_index(session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index(session_maker, "states", "ix_states_entity_id_last_updated_ts") _create_index(session_maker, "states", "ix_states_last_updated_ts") - _migrate_columns_to_timestamp(session_maker, engine) + _migrate_columns_to_timestamp(instance, session_maker, engine) elif new_version == 32: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. @@ -966,7 +967,7 @@ def _apply_update( # noqa: C901 "ix_statistics_short_term_statistic_id_start_ts", ) try: - _migrate_statistics_columns_to_timestamp(session_maker, engine) + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) except IntegrityError as ex: _LOGGER.error( "Statistics table contains duplicate entries: %s; " @@ -979,7 +980,7 @@ def _apply_update( # noqa: C901 # and try again with session_scope(session=session_maker()) as session: delete_statistics_duplicates(instance, hass, session) - _migrate_statistics_columns_to_timestamp(session_maker, engine) + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) # Log at error level to ensure the user sees this message in the log # since we logged the error above. _LOGGER.error( @@ -1195,8 +1196,9 @@ def _wipe_old_string_time_columns( session.commit() +@database_job_retry_wrapper("Migrate columns to timestamp", 3) def _migrate_columns_to_timestamp( - session_maker: Callable[[], Session], engine: Engine + instance: Recorder, session_maker: Callable[[], Session], engine: Engine ) -> None: """Migrate columns to use timestamp.""" # Migrate all data in Events.time_fired to Events.time_fired_ts @@ -1283,8 +1285,9 @@ def _migrate_columns_to_timestamp( ) +@database_job_retry_wrapper("Migrate statistics columns to timestamp", 3) def _migrate_statistics_columns_to_timestamp( - session_maker: Callable[[], Session], engine: Engine + instance: Recorder, session_maker: Callable[[], Session], engine: Engine ) -> None: """Migrate statistics columns to use timestamp.""" # Migrate all data in statistics.start to statistics.start_ts @@ -1355,10 +1358,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: - return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) return None @@ -1439,12 +1448,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1470,7 +1482,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1502,14 +1516,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1537,7 +1554,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], @@ -1568,7 +1587,7 @@ def post_migrate_entity_ids(instance: Recorder) -> bool: if is_done: # Drop the old indexes since they are no longer needed - _drop_index(session_maker, "states", "ix_states_entity_id_last_updated_ts") + _drop_index(session_maker, "states", LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX) _LOGGER.debug("Cleanup legacy entity_ids done=%s", is_done) return is_done diff --git a/homeassistant/components/recorder/models/context.py b/homeassistant/components/recorder/models/context.py index dbd9383bdeb..f722c519833 100644 --- a/homeassistant/components/recorder/models/context.py +++ b/homeassistant/components/recorder/models/context.py @@ -3,23 +3,36 @@ from __future__ import annotations from contextlib import suppress from functools import lru_cache +import logging from uuid import UUID from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes +_LOGGER = logging.getLogger(__name__) + def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: """Convert an ulid to bytes.""" if ulid is None: return None - return ulid_to_bytes(ulid) + try: + return ulid_to_bytes(ulid) + except ValueError as ex: + _LOGGER.error("Error converting ulid %s to bytes: %s", ulid, ex, exc_info=True) + return None def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: """Convert bytes to a ulid.""" if _bytes is None: return None - return bytes_to_ulid(_bytes) + try: + return bytes_to_ulid(_bytes) + except ValueError as ex: + _LOGGER.error( + "Error converting bytes %s to ulid: %s", _bytes, ex, exc_info=True + ) + return None @lru_cache(maxsize=16) diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index f983224e212..454c71f6dc5 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -730,7 +730,8 @@ def batch_cleanup_entity_ids() -> StatementLambdaElement: lambda: update(States) .where( States.state_id.in_( - select(States.state_id).join( + select(States.state_id) + .join( states_with_entity_ids := select( States.state_id.label("state_id_with_entity_id") ) @@ -739,6 +740,8 @@ def batch_cleanup_entity_ids() -> StatementLambdaElement: .subquery(), States.state_id == states_with_entity_ids.c.state_id_with_entity_id, ) + .alias("states_with_entity_ids") + .select() ) ) .values(entity_id=None) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0122ba4464b..70e82fad5d7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1034,18 +1034,19 @@ def _reduce_statistics_per_month( def _generate_statistics_during_period_stmt( - columns: Select, start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - stmt = lambda_stmt(lambda: columns.filter(table.start_ts >= start_time_ts)) + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.filter(table.start_ts >= start_time_ts) if end_time is not None: end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(table.start_ts < end_time_ts) @@ -1491,6 +1492,33 @@ def statistic_during_period( return {key: convert(value) if convert else value for key, value in result.items()} +_type_column_mapping = { + "last_reset": "last_reset_ts", + "max": "max", + "mean": "mean", + "min": "min", + "state": "state", + "sum": "sum", +} + + +def _generate_select_columns_for_types_stmt( + table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + columns = select(table.metadata_id, table.start_ts) + track_on: list[str | None] = [ + table.__tablename__, # type: ignore[attr-defined] + ] + for key, column in _type_column_mapping.items(): + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) + return lambda_stmt(lambda: columns, track_on=track_on) + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1525,21 +1553,8 @@ def _statistics_during_period_with_session( table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - columns = select(table.metadata_id, table.start_ts) # type: ignore[call-overload] - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table + start_time, end_time, metadata_ids, table, types ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) @@ -1771,34 +1786,34 @@ def get_latest_short_term_statistics( def _generate_statistics_at_time_stmt( - columns: Select, table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Create the statement for finding the statistics for a given time.""" - return lambda_stmt( - lambda: columns.join( - ( - most_recent_statistic_ids := ( - select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(table.start_ts).label("max_start_ts"), - table.metadata_id.label("max_metadata_id"), - ) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.join( + ( + most_recent_statistic_ids := ( + select( + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), ) - ), - and_( - table.start_ts == most_recent_statistic_ids.c.max_start_ts, - table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, - ), - ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ) + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), ) + return stmt def _statistics_at_time( @@ -1809,23 +1824,8 @@ def _statistics_at_time( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" - columns = select(table.metadata_id, table.start_ts) - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt( - columns, table, metadata_ids, start_time_ts - ) + stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 9db7c6ff100..135205aa95d 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -42,7 +42,7 @@ CONF_DIRECTION = "direction" CONF_DEPARTURE_TYPE = "departure_type" DEFAULT_NAME = "Next departure" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -98,6 +98,7 @@ class RejseplanenTransportSensor(SensorEntity): """Implementation of Rejseplanen transport sensor.""" _attr_attribution = "Data provided by rejseplanen.dk" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route, direction, name): """Initialize the sensor.""" @@ -143,11 +144,6 @@ class RejseplanenTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from rejseplanen.dk and update the states.""" self.data.update() diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index f309a8f188a..83d86745d90 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -85,43 +85,43 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( key="plugged_in", coordinator="battery", device_class=BinarySensorDeviceClass.PLUG, - name="Plugged in", on_key="plugStatus", on_value=PlugState.PLUGGED.value, + translation_key="plugged_in", ), RenaultBinarySensorEntityDescription( key="charging", coordinator="battery", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - name="Charging", on_key="chargingStatus", on_value=ChargeState.CHARGE_IN_PROGRESS.value, + translation_key="charging", ), RenaultBinarySensorEntityDescription( key="hvac_status", coordinator="hvac_status", icon_fn=lambda e: "mdi:fan" if e.is_on else "mdi:fan-off", - name="HVAC", on_key="hvacStatus", on_value="on", + translation_key="hvac_status", ), RenaultBinarySensorEntityDescription( key="lock_status", coordinator="lock_status", # lock: on means open (unlocked), off means closed (locked) device_class=BinarySensorDeviceClass.LOCK, - name="Lock", on_key="lockStatus", on_value="unlocked", + translation_key="lock_status", ), RenaultBinarySensorEntityDescription( key="hatch_status", coordinator="lock_status", # On means open, Off means closed device_class=BinarySensorDeviceClass.DOOR, - name="Hatch", on_key="hatchStatus", on_value="open", + translation_key="hatch_status", ), ] + [ @@ -130,9 +130,9 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( coordinator="lock_status", # On means open, Off means closed device_class=BinarySensorDeviceClass.DOOR, - name=f"{door.capitalize()} door", on_key=f"doorStatus{door.replace(' ','')}", on_value="open", + translation_key=f"{door.lower().replace(' ','_')}_door_status", ) for door in ("Rear Left", "Rear Right", "Driver", "Passenger") ], diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 67dfe8fc971..5f916a2d140 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultEntity +from .entity import RenaultEntity from .renault_hub import RenaultHub @@ -62,20 +62,20 @@ BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = ( async_press=lambda x: x.vehicle.set_ac_start(21, None), key="start_air_conditioner", icon="mdi:air-conditioner", - name="Start air conditioner", + translation_key="start_air_conditioner", ), RenaultButtonEntityDescription( async_press=lambda x: x.vehicle.set_charge_start(), key="start_charge", icon="mdi:ev-station", - name="Start charge", requires_electricity=True, + translation_key="start_charge", ), RenaultButtonEntityDescription( async_press=lambda x: x.vehicle.set_charge_stop(), key="stop_charge", icon="mdi:ev-station", - name="Stop charge", requires_electricity=True, + translation_key="stop_charge", ), ) diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/coordinator.py similarity index 100% rename from homeassistant/components/renault/renault_coordinator.py rename to homeassistant/components/renault/coordinator.py diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 87ca3c9eb5f..a27c59cecfb 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -55,6 +55,6 @@ DEVICE_TRACKER_TYPES: tuple[RenaultDataEntityDescription, ...] = ( key="location", coordinator="location", icon="mdi:car", - name="Location", + translation_key="location", ), ) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/entity.py similarity index 96% rename from homeassistant/components/renault/renault_entities.py rename to homeassistant/components/renault/entity.py index 188d429016a..aa83c935957 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .renault_coordinator import RenaultDataUpdateCoordinator, T +from .coordinator import RenaultDataUpdateCoordinator, T from .renault_vehicle import RenaultVehicleProxy diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 9580ea2b7d0..30e251dd30b 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN -from .renault_coordinator import RenaultDataUpdateCoordinator +from .coordinator import RenaultDataUpdateCoordinator LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 8fef7d9aee0..1ec891a51e4 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -92,7 +92,6 @@ SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = ( data_key="chargeMode", translation_key="charge_mode", icon_lambda=_get_charge_mode_icon, - name="Charge mode", options=["always", "always_charging", "schedule_mode"], ), ) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index d75dc55aa21..90ad70521df 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -37,8 +37,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import as_utc, parse_datetime from .const import DOMAIN -from .renault_coordinator import T -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .coordinator import T +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy @@ -163,9 +163,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="batteryLevel", device_class=SensorDeviceClass.BATTERY, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Battery level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + translation_key="battery_level", ), RenaultSensorEntityDescription( key="charge_state", @@ -175,7 +175,6 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.ENUM, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon_lambda=_get_charge_state_icon, - name="Charge state", options=[ "not_in_charge", "waiting_for_a_planned_charge", @@ -194,9 +193,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="chargingRemainingTime", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon="mdi:timer", - name="Charging remaining time", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + translation_key="charging_remaining_time", ), RenaultSensorEntityDescription( # For vehicles that DO NOT report charging power in watts, this seems to @@ -208,9 +207,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Admissible charging power", native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, + translation_key="admissible_charging_power", ), RenaultSensorEntityDescription( # For vehicles that DO report charging power in watts, this is the power @@ -221,10 +220,10 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Charging power", native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, value_lambda=_get_charging_power, + translation_key="charging_power", ), RenaultSensorEntityDescription( key="plug_state", @@ -234,7 +233,6 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.ENUM, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon_lambda=_get_plug_state_icon, - name="Plug state", options=["unplugged", "plugged", "plug_error", "plug_unknown"], value_lambda=_get_plug_state_formatted, ), @@ -245,9 +243,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.DISTANCE, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon="mdi:ev-station", - name="Battery autonomy", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + translation_key="battery_autonomy", ), RenaultSensorEntityDescription( key="battery_available_energy", @@ -255,9 +253,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="batteryAvailableEnergy", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], device_class=SensorDeviceClass.ENERGY, - name="Battery available energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, + translation_key="battery_available_energy", ), RenaultSensorEntityDescription( key="battery_temperature", @@ -265,9 +263,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="batteryTemperature", device_class=SensorDeviceClass.TEMPERATURE, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Battery temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + translation_key="battery_temperature", ), RenaultSensorEntityDescription( key="battery_last_activity", @@ -276,8 +274,8 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="timestamp", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], entity_registry_enabled_default=False, - name="Battery last activity", value_lambda=_get_utc_value, + translation_key="battery_last_activity", ), RenaultSensorEntityDescription( key="mileage", @@ -286,10 +284,10 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.DISTANCE, entity_class=RenaultSensor[KamereonVehicleCockpitData], icon="mdi:sign-direction", - name="Mileage", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, value_lambda=_get_rounded_value, + translation_key="mileage", ), RenaultSensorEntityDescription( key="fuel_autonomy", @@ -298,11 +296,11 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.DISTANCE, entity_class=RenaultSensor[KamereonVehicleCockpitData], icon="mdi:gas-station", - name="Fuel autonomy", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, requires_fuel=True, value_lambda=_get_rounded_value, + translation_key="fuel_autonomy", ), RenaultSensorEntityDescription( key="fuel_quantity", @@ -311,11 +309,11 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.VOLUME, entity_class=RenaultSensor[KamereonVehicleCockpitData], icon="mdi:fuel", - name="Fuel quantity", native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, requires_fuel=True, value_lambda=_get_rounded_value, + translation_key="fuel_quantity", ), RenaultSensorEntityDescription( key="outside_temperature", @@ -323,17 +321,17 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.TEMPERATURE, data_key="externalTemperature", entity_class=RenaultSensor[KamereonVehicleHvacStatusData], - name="Outside temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + translation_key="outside_temperature", ), RenaultSensorEntityDescription( key="hvac_soc_threshold", coordinator="hvac_status", data_key="socThreshold", entity_class=RenaultSensor[KamereonVehicleHvacStatusData], - name="HVAC SoC threshold", native_unit_of_measurement=PERCENTAGE, + translation_key="hvac_soc_threshold", ), RenaultSensorEntityDescription( key="hvac_last_activity", @@ -342,7 +340,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="lastUpdateTime", entity_class=RenaultSensor[KamereonVehicleHvacStatusData], entity_registry_enabled_default=False, - name="HVAC last activity", + translation_key="hvac_last_activity", value_lambda=_get_utc_value, ), RenaultSensorEntityDescription( @@ -352,7 +350,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="lastUpdateTime", entity_class=RenaultSensor[KamereonVehicleLocationData], entity_registry_enabled_default=False, - name="Location last activity", + translation_key="location_last_activity", value_lambda=_get_utc_value, ), RenaultSensorEntityDescription( @@ -360,7 +358,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( coordinator="res_state", data_key="details", entity_class=RenaultSensor[KamereonVehicleResStateData], - name="Remote engine start", + translation_key="res_state", ), RenaultSensorEntityDescription( key="res_state_code", @@ -368,6 +366,6 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="code", entity_class=RenaultSensor[KamereonVehicleResStateData], entity_registry_enabled_default=False, - name="Remote engine start code", + translation_key="res_state_code", ), ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index b28f1727c2f..066b49abcc0 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -33,8 +33,54 @@ } }, "entity": { + "binary_sensor": { + "charging": { + "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" + }, + "hatch_status": { + "name": "Hatch" + }, + "driver_door_status": { + "name": "Driver door" + }, + "hvac_status": { + "name": "HVAC" + }, + "lock_status": { + "name": "[%key:component::binary_sensor::entity_component::lock::name%]" + }, + "passenger_door_status": { + "name": "Passenger door" + }, + "plugged_in": { + "name": "[%key:component::binary_sensor::entity_component::plug::name%]" + }, + "rear_left_door_status": { + "name": "Rear left door" + }, + "rear_right_door_status": { + "name": "Rear right door" + } + }, + "button": { + "start_air_conditioner": { + "name": "Start air conditioner" + }, + "start_charge": { + "name": "Start charge" + }, + "stop_charge": { + "name": "Stop charge" + } + }, + "device_tracker": { + "location": { + "name": "Location" + } + }, "select": { "charge_mode": { + "name": "Charge mode", "state": { "always": "Instant", "always_charging": "Instant", @@ -43,15 +89,26 @@ } }, "sensor": { - "plug_state": { - "state": { - "unplugged": "Unplugged", - "plugged": "Plugged in", - "plug_error": "Plug error", - "plug_unknown": "Plug unknown" - } + "admissible_charging_power": { + "name": "Admissible charging power" + }, + "battery_autonomy": { + "name": "Battery autonomy" + }, + "battery_available_energy": { + "name": "Battery available energy" + }, + "battery_last_activity": { + "name": "Last battery activity" + }, + "battery_level": { + "name": "Battery level" + }, + "battery_temperature": { + "name": "Battery temperature" }, "charge_state": { + "name": "Charge state", "state": { "not_in_charge": "Not charging", "waiting_for_a_planned_charge": "Waiting for planned charge", @@ -62,6 +119,48 @@ "charge_error": "Not charging or plugged in", "unavailable": "Unavailable" } + }, + "charging_power": { + "name": "Charging power" + }, + "charging_remaining_time": { + "name": "Charging remaining time" + }, + "fuel_autonomy": { + "name": "Fuel autonomy" + }, + "fuel_quantity": { + "name": "Fuel quantity" + }, + "hvac_last_activity": { + "name": "Last HVAC activity" + }, + "hvac_soc_threshold": { + "name": "HVAC SoC threshold" + }, + "location_last_activity": { + "name": "Last location activity" + }, + "mileage": { + "name": "Mileage" + }, + "outside_temperature": { + "name": "Outside temperature" + }, + "plug_state": { + "name": "Plug state", + "state": { + "unplugged": "Unplugged", + "plugged": "Plugged in", + "plug_error": "Plug error", + "plug_unknown": "Plug unknown" + } + }, + "res_state": { + "name": "Remote engine start" + }, + "res_state_code": { + "name": "Remote engine start code" } } } diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 1a7649f367a..850aa110171 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -87,7 +87,7 @@ BINARY_SENSORS = ( icon="mdi:bell-ring-outline", icon_off="mdi:doorbell", value=lambda api, ch: api.visitor_detected(ch), - supported=lambda api, ch: api.is_doorbell_enabled(ch), + supported=lambda api, ch: api.is_doorbell(ch), ), ) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f7810746481..e6c90343229 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,6 +8,7 @@ from typing import Any import aiohttp from aiohttp.web import Request +import async_timeout from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError, SubscriptionError @@ -23,6 +24,7 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 +FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 _LOGGER = logging.getLogger(__name__) @@ -146,11 +148,13 @@ class ReolinkHost: "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url ) try: - await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): + await self._webhook_reachable.wait() except asyncio.TimeoutError: _LOGGER.debug( - "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, + FIRST_ONVIF_TIMEOUT, ) ir.async_create_issue( self._hass, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 79fc15c571d..73318f12be1 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.8"] + "requirements": ["reolink-aio==0.5.10"] } diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index a994b7d353b..d1da30a01a8 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -144,3 +144,4 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.method(self._host.api, self._channel, option) + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index c36001e0377..4c9b9f630f8 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -19,7 +19,7 @@ }, "error": { "api_error": "API error occurred", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%], check the IP address of the camera and see the troubleshooting steps in the documentation", + "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c8796c7161c..b6ec7eb8ecb 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -1,7 +1,7 @@ { "domain": "rest", "name": "RESTful", - "codeowners": [], + "codeowners": ["@epenet"], "documentation": "https://www.home-assistant.io/integrations/rest", "iot_class": "local_polling", "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"] diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f918..9e016db0376 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 08815cae9fb..7b437a4f8c4 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.6"] + "requirements": ["roombapy==1.6.8"] } diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 0d90157f76b..3406185b966 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -340,7 +340,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg.async_clear_config_entry(config_entry.entry_id) version = config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index fb00d58c7ac..3e91e8ab86d 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, time, timedelta import itertools -import logging from typing import Any, Literal import voluptuous as vol @@ -21,8 +20,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.collection import ( CollectionEntity, + DictStorageCollection, IDManager, - StorageCollection, + SerializedStorageCollection, StorageCollectionWebsocket, YamlCollection, sync_entity_lifecycle, @@ -173,7 +173,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: version=STORAGE_VERSION, minor_version=STORAGE_VERSION_MINOR, ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) sync_entity_lifecycle(hass, DOMAIN, DOMAIN, component, storage_collection, Schedule) @@ -210,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ScheduleStorageCollection(StorageCollection): +class ScheduleStorageCollection(DictStorageCollection): """Schedules stored in storage.""" SCHEMA = vol.Schema(BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA) @@ -226,12 +225,12 @@ class ScheduleStorageCollection(StorageCollection): name: str = info[CONF_NAME] return name - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" self.SCHEMA(update_data) - return data | update_data + return item | update_data - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> SerializedStorageCollection | None: """Load the data.""" if data := await super()._async_load_data(): data["items"] = [STORAGE_SCHEMA(item) for item in data["items"]] diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010c..3ca13e56b29 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -150,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -194,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848f..857d53eb527 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -125,5 +125,72 @@ } } } + }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } } } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 4f56be77a94..d0fdc8a0886 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -167,7 +167,6 @@ class SensorEntity(Entity): _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) - _invalid_numeric_value_reported = False _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False @@ -463,7 +462,7 @@ class SensorEntity(Entity): @final @property - def state(self) -> Any: # noqa: C901 + def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement @@ -581,33 +580,13 @@ class SensorEntity(Entity): else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: - # Raise if precision is not None, for other cases log a warning - if suggested_precision is not None: - raise ValueError( - f"Sensor {self.entity_id} has device class {device_class}, " - f"state class {state_class} unit {unit_of_measurement} and " - f"suggested precision {suggested_precision} thus indicating it " - f"has a numeric value; however, it has the non-numeric value: " - f"{value} ({type(value)})" - ) from err - # This should raise in Home Assistant Core 2023.4 - if not self._invalid_numeric_value_reported: - self._invalid_numeric_value_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Sensor %s has device class %s, state class %s and unit %s " - "thus indicating it has a numeric value; however, it has the " - "non-numeric value: %s (%s); Please update your configuration " - "if your entity is manually configured, otherwise %s", - self.entity_id, - device_class, - state_class, - unit_of_measurement, - value, - type(value), - report_issue, - ) - return value + raise ValueError( + f"Sensor {self.entity_id} has device class {device_class}, " + f"state class {state_class} unit {unit_of_measurement} and " + f"suggested precision {suggested_precision} thus indicating it " + f"has a numeric value; however, it has the non-numeric value: " + f"{value} ({type(value)})" + ) from err else: numerical_value = value diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 892bc611b3d..e829c8a8e49 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -75,7 +75,7 @@ class SensorDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s` + Unit of measurement: `d`, `h`, `min`, `s`, `ms` """ ENUM = "enum" diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 16e0da0d518..262f7033a41 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -197,7 +197,7 @@ "name": "Ozone" }, "pm1": { - "name": "Particulate matter 0.1 μm" + "name": "Particulate matter 1 μm" }, "pm10": { "name": "Particulate matter 10 μm" diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 95eff4e7a55..066549e2629 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.16.0"] + "requirements": ["sentry-sdk==1.18.0"] } diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 8758764a14c..e4d41fb0cb8 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -42,28 +42,28 @@ class SFRBoxBinarySensorEntityDescription( DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( SFRBoxBinarySensorEntityDescription[DslInfo]( key="status", - name="DSL status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", + translation_key="dsl_status", ), ) FTTH_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[FtthInfo], ...] = ( SFRBoxBinarySensorEntityDescription[FtthInfo]( key="status", - name="FTTH status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", + translation_key="ftth_status", ), ) WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( SFRBoxBinarySensorEntityDescription[WanInfo]( key="status", - name="WAN status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", + translation_key="wan_status", ), ) diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index 32ebeac97ac..f6741da1398 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -66,7 +66,7 @@ BUTTON_TYPES: tuple[SFRBoxButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, key="system_reboot", - name="Reboot", + translation_key="reboot", ), ) diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index d276d308214..19512f43821 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -46,84 +46,83 @@ class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( SFRBoxSensorEntityDescription[DslInfo]( key="linemode", - name="DSL line mode", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + translation_key="dsl_linemode", value_fn=lambda x: x.linemode, ), SFRBoxSensorEntityDescription[DslInfo]( key="counter", - name="DSL counter", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + translation_key="dsl_counter", value_fn=lambda x: x.counter, ), SFRBoxSensorEntityDescription[DslInfo]( key="crc", - name="DSL CRC", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + translation_key="dsl_crc", value_fn=lambda x: x.crc, ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_down", - name="DSL noise down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_noise_down", value_fn=lambda x: x.noise_down, ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_up", - name="DSL noise up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_noise_up", value_fn=lambda x: x.noise_up, ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_down", - name="DSL attenuation down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_attenuation_down", value_fn=lambda x: x.attenuation_down, ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_up", - name="DSL attenuation up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_attenuation_up", value_fn=lambda x: x.attenuation_up, ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_down", - name="DSL rate down", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_rate_down", value_fn=lambda x: x.rate_down, ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_up", - name="DSL rate up", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_rate_up", value_fn=lambda x: x.rate_up, ), SFRBoxSensorEntityDescription[DslInfo]( key="line_status", - name="DSL line status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -135,12 +134,11 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "loss_of_signal_quality", "unknown", ], - translation_key="line_status", + translation_key="dsl_line_status", value_fn=lambda x: x.line_status.lower().replace(" ", "_"), ), SFRBoxSensorEntityDescription[DslInfo]( key="training", - name="DSL training", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -156,14 +154,13 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "showtime", "unknown", ], - translation_key="training", + translation_key="dsl_training", value_fn=lambda x: x.training.lower().replace(" ", "_").replace(".", "_"), ), ) SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( SFRBoxSensorEntityDescription[SystemInfo]( key="net_infra", - name="Network infrastructure", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -178,27 +175,26 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( ), SFRBoxSensorEntityDescription[SystemInfo]( key="alimvoltage", - name="Voltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + translation_key="voltage", value_fn=lambda x: x.alimvoltage, ), SFRBoxSensorEntityDescription[SystemInfo]( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="temperature", value_fn=lambda x: x.temperature / 1000, ), ) WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( SFRBoxSensorEntityDescription[WanInfo]( key="mode", - name="WAN mode", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 2141abad872..cf74e9eb656 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -31,8 +31,40 @@ } }, "entity": { + "binary_sensor": { + "dsl_status": { + "name": "DSL status" + }, + "ftth_status": { + "name": "FTTH status" + }, + "wan_status": { + "name": "WAN status" + } + }, + "button": { + "reboot": { + "name": "[%key:component::button::entity_component::restart::name%]" + } + }, "sensor": { - "line_status": { + "dsl_attenuation_down": { + "name": "DSL attenuation down" + }, + "dsl_attenuation_up": { + "name": "DSL attenuation up" + }, + "dsl_crc": { + "name": "DSL CRC" + }, + "dsl_counter": { + "name": "DSL counter" + }, + "dsl_linemode": { + "name": "DSL line mode" + }, + "dsl_line_status": { + "name": "DSL line status", "state": { "no_defect": "No Defect", "of_frame": "Of Frame", @@ -42,15 +74,20 @@ "unknown": "Unknown" } }, - "net_infra": { - "state": { - "adsl": "ADSL", - "ftth": "FTTH", - "gprs": "GPRS", - "unknown": "Unknown" - } + "dsl_noise_down": { + "name": "DSL noise down" }, - "training": { + "dsl_noise_up": { + "name": "DSL noise up" + }, + "dsl_rate_down": { + "name": "DSL rate down" + }, + "dsl_rate_up": { + "name": "DSL rate up" + }, + "dsl_training": { + "name": "DSL training", "state": { "idle": "Idle", "g_994_training": "G.994 Training", @@ -64,7 +101,23 @@ "unknown": "Unknown" } }, + "net_infra": { + "name": "Network infrastructure", + "state": { + "adsl": "ADSL", + "ftth": "FTTH", + "gprs": "GPRS", + "unknown": "Unknown" + } + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, "wan_mode": { + "name": "WAN mode", "state": { "adsl_ppp": "ADSL (PPP)", "adsl_routed": "ADSL (Routed)", diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 738dd595a5a..b6cae8ad605 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -17,7 +17,14 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS, SHARKIQ_REGION_EUROPE +from .const import ( + API_TIMEOUT, + DOMAIN, + LOGGER, + PLATFORMS, + SHARKIQ_REGION_DEFAULT, + SHARKIQ_REGION_EUROPE, +) from .update_coordinator import SharkIqUpdateCoordinator @@ -43,6 +50,12 @@ async def async_connect_or_timeout(ayla_api: AylaApi) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Initialize the sharkiq platform via config entry.""" + if CONF_REGION not in config_entry.data: + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, CONF_REGION: SHARKIQ_REGION_DEFAULT}, + ) + ayla_api = get_ayla_api( username=config_entry.data[CONF_USERNAME], password=config_entry.data[CONF_PASSWORD], diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index bd08e19c4e2..3dc26fe007a 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -18,6 +18,8 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonArrayType, load_json_array from .const import ( + ATTR_REVERSE, + DEFAULT_REVERSE, DOMAIN, EVENT_SHOPPING_LIST_UPDATED, SERVICE_ADD_ITEM, @@ -27,6 +29,7 @@ from .const import ( SERVICE_INCOMPLETE_ALL, SERVICE_INCOMPLETE_ITEM, SERVICE_REMOVE_ITEM, + SERVICE_SORT, ) ATTR_COMPLETE = "complete" @@ -38,6 +41,9 @@ PERSISTENCE = ".shopping_list.json" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) SERVICE_LIST_SCHEMA = vol.Schema({}) +SERVICE_SORT_SCHEMA = vol.Schema( + {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} +) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -111,6 +117,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Clear all completed items from the list.""" await data.async_clear_completed() + async def sort_list_service(call: ServiceCall) -> None: + """Sort all items by name.""" + await data.async_sort(call.data[ATTR_REVERSE]) + data = hass.data[DOMAIN] = ShoppingData(hass) await data.async_load() @@ -147,6 +157,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b clear_completed_items_service, schema=SERVICE_LIST_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_SORT, + sort_list_service, + schema=SERVICE_SORT_SCHEMA, + ) hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) @@ -277,6 +293,16 @@ class ShoppingData: context=context, ) + async def async_sort(self, reverse=False, context=None): + """Sort items by name.""" + self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) + self.hass.async_add_executor_job(self.save) + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "sorted"}, + context=context, + ) + async def async_load(self) -> None: """Load items.""" diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index 05dc05137c0..c519123a414 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -2,6 +2,10 @@ DOMAIN = "shopping_list" EVENT_SHOPPING_LIST_UPDATED = "shopping_list_updated" +ATTR_REVERSE = "reverse" + +DEFAULT_REVERSE = False + SERVICE_ADD_ITEM = "add_item" SERVICE_REMOVE_ITEM = "remove_item" SERVICE_COMPLETE_ITEM = "complete_item" @@ -9,3 +13,4 @@ SERVICE_INCOMPLETE_ITEM = "incomplete_item" SERVICE_COMPLETE_ALL = "complete_all" SERVICE_INCOMPLETE_ALL = "incomplete_all" SERVICE_CLEAR_COMPLETED_ITEMS = "clear_completed_items" +SERVICE_SORT = "sort" diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index c41bc1333dc..250912f49cd 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -56,3 +56,14 @@ incomplete_all: clear_completed_items: name: Clear completed items description: Clear completed items from the shopping list. + +sort: + name: Sort all items + description: Sort all items by name in the shopping list. + fields: + reverse: + name: Sort reverse + description: Whether to sort in reverse (descending) order. + default: false + selector: + boolean: diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py index 6195a5fd1d9..101e7cb35fd 100644 --- a/homeassistant/components/simplepush/const.py +++ b/homeassistant/components/simplepush/const.py @@ -6,6 +6,7 @@ DOMAIN: Final = "simplepush" DEFAULT_NAME: Final = "simplepush" DATA_HASS_CONFIG: Final = "simplepush_hass_config" +ATTR_ATTACHMENTS: Final = "attachments" ATTR_ENCRYPTED: Final = "encrypted" ATTR_EVENT: Final = "event" diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index b1c2eb5680e..3e7fad8863f 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN # Configuring Simplepush under the notify has been removed in 2022.9.0 PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA @@ -61,11 +61,34 @@ class SimplePushNotificationService(BaseNotificationService): """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + attachments = None # event can now be passed in the service data event = None if data := kwargs.get(ATTR_DATA): event = data.get(ATTR_EVENT) + attachments_data = data.get(ATTR_ATTACHMENTS) + if isinstance(attachments_data, list): + attachments = [] + for attachment in attachments_data: + if not ( + isinstance(attachment, dict) + and ( + "image" in attachment + or "video" in attachment + or ("video" in attachment and "thumbnail" in attachment) + ) + ): + _LOGGER.error("Attachment format is incorrect") + return + + if "video" in attachment and "thumbnail" in attachment: + attachments.append(attachment) + elif "video" in attachment: + attachments.append(attachment["video"]) + elif "image" in attachment: + attachments.append(attachment["image"]) + # use event from config until YAML config is removed event = event or self._event @@ -77,10 +100,17 @@ class SimplePushNotificationService(BaseNotificationService): salt=self._salt, title=title, message=message, + attachments=attachments, event=event, ) else: - send(key=self._device_key, title=title, message=message, event=event) + send( + key=self._device_key, + title=title, + message=message, + attachments=attachments, + event=event, + ) except BadRequest: _LOGGER.error("Bad request. Title or message are too long") diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 6849c9648d0..6b26eb7854b 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -1,7 +1,7 @@ """Support for SimpliSafe binary sensors.""" from __future__ import annotations -from simplipy.device import DeviceTypes +from simplipy.device import DeviceTypes, DeviceV3 from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 @@ -67,6 +67,9 @@ async def async_setup_entry( if sensor.type in SUPPORTED_BATTERY_SENSOR_TYPES: sensors.append(BatteryBinarySensor(simplisafe, system, sensor)) + for lock in system.locks.values(): + sensors.append(BatteryBinarySensor(simplisafe, system, lock)) + async_add_entities(sensors) @@ -99,14 +102,14 @@ class BatteryBinarySensor(SimpliSafeEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( - self, simplisafe: SimpliSafe, system: SystemV3, sensor: SensorV3 + self, simplisafe: SimpliSafe, system: SystemV3, device: DeviceV3 ) -> None: """Initialize.""" - super().__init__(simplisafe, system, device=sensor) + super().__init__(simplisafe, system, device=device) self._attr_name = "Battery" self._attr_unique_id = f"{super().unique_id}-battery" - self._device: SensorV3 + self._device: DeviceV3 @callback def async_update_from_rest_api(self) -> None: diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 9a5a391240a..184e1f306e0 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["simplipy"], - "requirements": ["simplisafe-python==2022.12.0"] + "requirements": ["simplisafe-python==2023.04.0"] } diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index f2e64655acc..0f9db48e78c 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -34,8 +34,6 @@ DEFAULT_SEED = 999 DEFAULT_UNIT = "value" DEFAULT_RELATIVE_TO_EPOCH = True -ICON = "mdi:chart-line" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), @@ -79,6 +77,8 @@ def setup_platform( class SimulatedSensor(SensorEntity): """Class for simulated sensor.""" + _attr_icon = "mdi:chart-line" + def __init__( self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch ): @@ -135,11 +135,6 @@ class SimulatedSensor(SensorEntity): """Return the state of the sensor.""" return self._state - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index b179daaf1a8..828e4a68121 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -10,7 +10,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN SWITCH_PREFIX = "Switch" -ICON = "mdi:toggle-switch" async def async_setup_entry( @@ -55,6 +54,8 @@ async def async_setup_entry( class SmappeeActuator(SwitchEntity): """Representation of a Smappee Comport Plug.""" + _attr_icon = "mdi:toggle-switch" + def __init__( self, smappee_base, @@ -105,11 +106,6 @@ class SmappeeActuator(SwitchEntity): # Switch or comfort plug return self._state == "ON_ON" - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - def turn_on(self, **kwargs: Any) -> None: """Turn on Comport Plug.""" if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index d72bda048bb..cfa31d56e80 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -17,7 +17,7 @@ from .const import DOMAIN, GATEWAY, NETWORK_COORDINATOR, SIGNAL_COORDINATOR, SMS SIGNAL_SENSORS = ( SensorEntityDescription( key="SignalStrength", - name="Signal Strength", + translation_key="signal_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -27,14 +27,14 @@ SIGNAL_SENSORS = ( SensorEntityDescription( key="SignalPercent", icon="mdi:signal-cellular-3", - name="Signal Percent", + translation_key="signal_percent", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="BitErrorRate", - name="Bit Error Rate", + translation_key="bit_error_rate", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, @@ -45,31 +45,31 @@ SIGNAL_SENSORS = ( NETWORK_SENSORS = ( SensorEntityDescription( key="NetworkName", - name="Network Name", + translation_key="network_name", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( key="State", - name="Network Status", + translation_key="state", entity_registry_enabled_default=True, ), SensorEntityDescription( key="NetworkCode", - name="GSM network code", + translation_key="network_code", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( key="CID", - name="Cell ID", + translation_key="cid", icon="mdi:radio-tower", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( key="LAC", - name="Local Area Code", + translation_key="lac", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -102,6 +102,8 @@ async def async_setup_entry( class DeviceSensor(CoordinatorEntity, SensorEntity): """Implementation of a device sensor.""" + _attr_has_entity_name = True + def __init__(self, coordinator, description, unique_id, gateway): """Initialize the device sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/sms/strings.json b/homeassistant/components/sms/strings.json index b4a9279845d..6bf8cbcc166 100644 --- a/homeassistant/components/sms/strings.json +++ b/homeassistant/components/sms/strings.json @@ -17,5 +17,19 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "bit_error_rate": { "name": "Bit error rate" }, + "cid": { "name": "Cell ID" }, + "lac": { "name": "Local area code" }, + "network_code": { "name": "GSM network code" }, + "network_name": { "name": "Network name" }, + "signal_percent": { "name": "Signal percent" }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "state": { "name": "Network status" } + } } } diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 6f965155bba..4fd7c587d40 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -42,6 +42,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} ) +STREAM_STATUS = { + "idle": MediaPlayerState.IDLE, + "playing": MediaPlayerState.PLAYING, + "unknown": None, +} + def register_services(): """Register snapcast services.""" @@ -157,11 +163,9 @@ class SnapcastGroupDevice(MediaPlayerEntity): @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - return { - "idle": MediaPlayerState.IDLE, - "playing": MediaPlayerState.PLAYING, - "unknown": None, - }.get(self._group.stream_status) + if self.is_volume_muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._group.stream_status) @property def unique_id(self): @@ -289,11 +293,13 @@ class SnapcastClientDevice(MediaPlayerEntity): return list(self._client.group.streams_by_name().keys()) @property - def state(self) -> MediaPlayerState: + def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._client.connected: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if self.is_volume_muted or self._client.group.muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._client.group.stream_status) + return MediaPlayerState.STANDBY @property def extra_state_attributes(self): diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c2..638ede722f5 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -591,13 +591,20 @@ class SonosSpeaker: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 2fed7d97948..7441079b193 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.7"] + "requirements": ["sqlalchemy==2.0.8"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index c19c2c258bc..8408b98730b 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -30,6 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -153,10 +154,44 @@ async def async_setup_sensor( ): return + upper_query = query_str.upper() + if use_database_executor: + redacted_query = redact_credentials(query_str) + + issue_key = unique_id if unique_id else redacted_query + # If the query has a unique id and they fix it we can dismiss the issue + # but if it doesn't have a unique id they have to ignore it instead + + if "ENTITY_ID" in upper_query and "STATES_META" not in upper_query: + _LOGGER.error( + "The query `%s` contains the keyword `entity_id` but does not " + "reference the `states_meta` table. This will cause a full table " + "scan and database instability. Please check the documentation and use " + "`states_meta.entity_id` instead", + redacted_query, + ) + + ir.async_create_issue( + hass, + DOMAIN, + f"entity_id_query_does_full_table_scan_{issue_key}", + translation_key="entity_id_query_does_full_table_scan", + translation_placeholders={"query": redacted_query}, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + ) + raise ValueError( + "Query contains entity_id but does not reference states_meta" + ) + + ir.async_delete_issue( + hass, DOMAIN, f"entity_id_query_does_full_table_scan_{issue_key}" + ) + # MSSQL uses TOP and not LIMIT - if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()): + if not ("LIMIT" in upper_query or "SELECT TOP" in upper_query): if "mssql" in db_url: - query_str = query_str.upper().replace("SELECT", "SELECT TOP 1") + query_str = upper_query.replace("SELECT", "SELECT TOP 1") else: query_str = query_str.replace(";", "") + " LIMIT 1;" diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 2a300f75b3e..1e7aef4ffde 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -53,5 +53,11 @@ "db_url_invalid": "[%key:component::sql::config::error::db_url_invalid%]", "query_invalid": "[%key:component::sql::config::error::query_invalid%]" } + }, + "issues": { + "entity_id_query_does_full_table_scan": { + "title": "SQL query does full table scan", + "description": "The query `{query}` contains the keyword `entity_id` but does not reference the `states_meta` table. This will cause a full table scan and database instability. Please check the documentation and use `states_meta.entity_id` instead." + } } } diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 527a1ed78b1..cbc70786166 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -6,10 +6,8 @@ DEFAULT_NAME = "SRP Energy" CONF_IS_TOU = "is_tou" -ATTRIBUTION = "Powered by SRP Energy" + MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) SENSOR_NAME = "Usage" SENSOR_TYPE = "usage" - -ICON = "mdi:flash" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 1aaf5175e53..a919bba1b22 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -17,9 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - ATTRIBUTION, DEFAULT_NAME, - ICON, MIN_TIME_BETWEEN_UPDATES, SENSOR_NAME, SENSOR_TYPE, @@ -83,7 +81,8 @@ async def async_setup_entry( class SrpEntity(SensorEntity): """Implementation of a Srp Energy Usage sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Powered by SRP Energy" + _attr_icon = "mdi:flash" _attr_should_poll = False def __init__(self, coordinator): @@ -116,11 +115,6 @@ class SrpEntity(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Return icon.""" - return ICON - @property def usage(self): """Return entity state.""" diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index b7ed28885cb..570e79e4993 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -106,42 +106,20 @@ PRIMARY_MATCH_KEYS = [ _LOGGER = logging.getLogger(__name__) -@dataclass -class _HaServiceDescription: - """Keys added by HA.""" - - x_homeassistant_matching_domains: set[str] = field(default_factory=set) - - -@dataclass -class _SsdpServiceDescription: - """SSDP info with optional keys.""" +@dataclass(slots=True) +class SsdpServiceInfo(BaseServiceInfo): + """Prepared info from ssdp/upnp entries.""" ssdp_usn: str ssdp_st: str + upnp: Mapping[str, Any] ssdp_location: str | None = None ssdp_nt: str | None = None ssdp_udn: str | None = None ssdp_ext: str | None = None ssdp_server: str | None = None ssdp_headers: Mapping[str, Any] = field(default_factory=dict) - - -@dataclass -class _UpnpServiceDescription: - """UPnP info.""" - - upnp: Mapping[str, Any] - - -@dataclass -class SsdpServiceInfo( - _HaServiceDescription, - _SsdpServiceDescription, - _UpnpServiceDescription, - BaseServiceInfo, -): - """Prepared info from ssdp/upnp entries.""" + x_homeassistant_matching_domains: set[str] = field(default_factory=set) SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") @@ -401,7 +379,7 @@ class Scanner: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL, "SSDP scanner" + self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" ) # Trigger the initial-scan. diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 350c420d5d6..f4a87837878 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -26,7 +26,7 @@ CONF_SANDBOX = "sandbox" DEFAULT_SANDBOX = False DEFAULT_ACCOUNT_NAME = "Starling" -ICON = "mdi:currency-gbp" + SCAN_INTERVAL = timedelta(seconds=180) ACCOUNT_SCHEMA = vol.Schema( @@ -76,6 +76,8 @@ def setup_platform( class StarlingBalanceSensor(SensorEntity): """Representation of a Starling balance sensor.""" + _attr_icon = "mdi:currency-gbp" + def __init__(self, starling_account, account_name, balance_data_type): """Initialize the sensor.""" self._starling_account = starling_account @@ -100,11 +102,6 @@ class StarlingBalanceSensor(SensorEntity): """Return the unit of measurement.""" return self._starling_account.currency - @property - def icon(self): - """Return the entity icon.""" - return ICON - def update(self) -> None: """Fetch new state data for the sensor.""" self._starling_account.update_balance_data() diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 63199402194..50eac1dfeb7 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -1,11 +1,8 @@ """Provide functionality to STT.""" from __future__ import annotations -from abc import ABC, abstractmethod import asyncio -from collections.abc import AsyncIterable -from dataclasses import asdict, dataclass -import logging +from dataclasses import asdict from typing import Any from aiohttp import web @@ -17,10 +14,8 @@ from aiohttp.web_exceptions import ( ) from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_prepare_setup_platform from .const import ( DOMAIN, @@ -31,151 +26,40 @@ from .const import ( AudioSampleRates, SpeechResultState, ) +from .legacy import ( + Provider, + SpeechMetadata, + SpeechResult, + async_get_provider, + async_setup_legacy, +) -_LOGGER = logging.getLogger(__name__) - - -@callback -def async_get_provider(hass: HomeAssistant, domain: str | None = None) -> Provider: - """Return provider.""" - if domain is None: - domain = next(iter(hass.data[DOMAIN])) - - return hass.data[DOMAIN][domain] +__all__ = [ + "async_get_provider", + "AudioBitRates", + "AudioChannels", + "AudioCodecs", + "AudioFormats", + "AudioSampleRates", + "DOMAIN", + "Provider", + "SpeechMetadata", + "SpeechResult", + "SpeechResultState", +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up STT.""" - providers = hass.data[DOMAIN] = {} + platform_setups = async_setup_legacy(hass, config) - async def async_setup_platform(p_type, p_config=None, discovery_info=None): - """Set up a TTS platform.""" - if p_config is None: - p_config = {} + if platform_setups: + await asyncio.wait([asyncio.create_task(setup) for setup in platform_setups]) - platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) - if platform is None: - return - - try: - provider = await platform.async_get_engine(hass, p_config, discovery_info) - if provider is None: - _LOGGER.error("Error setting up platform %s", p_type) - return - - provider.name = p_type - provider.hass = hass - - providers[provider.name] = provider - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform: %s", p_type) - return - - setup_tasks = [ - asyncio.create_task(async_setup_platform(p_type, p_config)) - for p_type, p_config in config_per_platform(config, DOMAIN) - ] - - if setup_tasks: - await asyncio.wait(setup_tasks) - - # Add discovery support - async def async_platform_discovered(platform, info): - """Handle for discovered platform.""" - await async_setup_platform(platform, discovery_info=info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - - hass.http.register_view(SpeechToTextView(providers)) + hass.http.register_view(SpeechToTextView(hass.data[DOMAIN])) return True -@dataclass -class SpeechMetadata: - """Metadata of audio stream.""" - - language: str - format: AudioFormats - codec: AudioCodecs - bit_rate: AudioBitRates - sample_rate: AudioSampleRates - channel: AudioChannels - - def __post_init__(self) -> None: - """Finish initializing the metadata.""" - self.bit_rate = AudioBitRates(int(self.bit_rate)) - self.sample_rate = AudioSampleRates(int(self.sample_rate)) - self.channel = AudioChannels(int(self.channel)) - - -@dataclass -class SpeechResult: - """Result of audio Speech.""" - - text: str | None - result: SpeechResultState - - -class Provider(ABC): - """Represent a single STT provider.""" - - hass: HomeAssistant | None = None - name: str | None = None - - @property - @abstractmethod - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - - @property - @abstractmethod - def supported_formats(self) -> list[AudioFormats]: - """Return a list of supported formats.""" - - @property - @abstractmethod - def supported_codecs(self) -> list[AudioCodecs]: - """Return a list of supported codecs.""" - - @property - @abstractmethod - def supported_bit_rates(self) -> list[AudioBitRates]: - """Return a list of supported bit rates.""" - - @property - @abstractmethod - def supported_sample_rates(self) -> list[AudioSampleRates]: - """Return a list of supported sample rates.""" - - @property - @abstractmethod - def supported_channels(self) -> list[AudioChannels]: - """Return a list of supported channels.""" - - @abstractmethod - async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] - ) -> SpeechResult: - """Process an audio stream to STT service. - - Only streaming of content are allow! - """ - - @callback - def check_metadata(self, metadata: SpeechMetadata) -> bool: - """Check if given metadata supported by this provider.""" - if ( - metadata.language not in self.supported_languages - or metadata.format not in self.supported_formats - or metadata.codec not in self.supported_codecs - or metadata.bit_rate not in self.supported_bit_rates - or metadata.sample_rate not in self.supported_sample_rates - or metadata.channel not in self.supported_channels - ): - return False - return True - - class SpeechToTextView(HomeAssistantView): """STT view to generate a text from audio stream.""" @@ -195,7 +79,7 @@ class SpeechToTextView(HomeAssistantView): # Get metadata try: - metadata = metadata_from_header(request) + metadata = _metadata_from_header(request) except ValueError as err: raise HTTPBadRequest(text=str(err)) from err @@ -229,7 +113,7 @@ class SpeechToTextView(HomeAssistantView): ) -def metadata_from_header(request: web.Request) -> SpeechMetadata: +def _metadata_from_header(request: web.Request) -> SpeechMetadata: """Extract STT metadata from header. X-Speech-Content: diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py new file mode 100644 index 00000000000..2f826e0be9e --- /dev/null +++ b/homeassistant/components/stt/legacy.py @@ -0,0 +1,169 @@ +"""Handle legacy speech to text platforms.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_prepare_setup_platform + +from .const import ( + DOMAIN, + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_provider( + hass: HomeAssistant, domain: str | None = None +) -> Provider | None: + """Return provider.""" + if domain: + return hass.data[DOMAIN].get(domain) + + if not hass.data[DOMAIN]: + return None + + if "cloud" in hass.data[DOMAIN]: + return hass.data[DOMAIN]["cloud"] + + return next(iter(hass.data[DOMAIN].values())) + + +@callback +def async_setup_legacy( + hass: HomeAssistant, config: ConfigType +) -> list[Coroutine[Any, Any, None]]: + """Set up legacy speech to text providers.""" + providers = hass.data[DOMAIN] = {} + + async def async_setup_platform(p_type, p_config=None, discovery_info=None): + """Set up a TTS platform.""" + if p_config is None: + p_config = {} + + platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) + if platform is None: + _LOGGER.error("Unknown speech to text platform specified") + return + + try: + provider = await platform.async_get_engine(hass, p_config, discovery_info) + + provider.name = p_type + provider.hass = hass + + providers[provider.name] = provider + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error setting up platform: %s", p_type) + return + + # Add discovery support + async def async_platform_discovered(platform, info): + """Handle for discovered platform.""" + await async_setup_platform(platform, discovery_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + + return [ + async_setup_platform(p_type, p_config) + for p_type, p_config in config_per_platform(config, DOMAIN) + ] + + +@dataclass +class SpeechMetadata: + """Metadata of audio stream.""" + + language: str + format: AudioFormats + codec: AudioCodecs + bit_rate: AudioBitRates + sample_rate: AudioSampleRates + channel: AudioChannels + + def __post_init__(self) -> None: + """Finish initializing the metadata.""" + self.bit_rate = AudioBitRates(int(self.bit_rate)) + self.sample_rate = AudioSampleRates(int(self.sample_rate)) + self.channel = AudioChannels(int(self.channel)) + + +@dataclass +class SpeechResult: + """Result of audio Speech.""" + + text: str | None + result: SpeechResultState + + +class Provider(ABC): + """Represent a single STT provider.""" + + hass: HomeAssistant | None = None + name: str | None = None + + @property + @abstractmethod + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + + @property + @abstractmethod + def supported_formats(self) -> list[AudioFormats]: + """Return a list of supported formats.""" + + @property + @abstractmethod + def supported_codecs(self) -> list[AudioCodecs]: + """Return a list of supported codecs.""" + + @property + @abstractmethod + def supported_bit_rates(self) -> list[AudioBitRates]: + """Return a list of supported bit rates.""" + + @property + @abstractmethod + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported sample rates.""" + + @property + @abstractmethod + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + + @abstractmethod + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service. + + Only streaming of content are allow! + """ + + @callback + def check_metadata(self, metadata: SpeechMetadata) -> bool: + """Check if given metadata supported by this provider.""" + if ( + metadata.language not in self.supported_languages + or metadata.format not in self.supported_formats + or metadata.codec not in self.supported_codecs + or metadata.bit_rate not in self.supported_bit_rates + or metadata.sample_rate not in self.supported_sample_rates + or metadata.channel not in self.supported_channels + ): + return False + return True diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index b594f8f91be..73eb0fa4c07 100644 --- a/homeassistant/components/stt/manifest.json +++ b/homeassistant/components/stt/manifest.json @@ -1,7 +1,7 @@ { "domain": "stt", "name": "Speech-to-Text (STT)", - "codeowners": ["@pvizeli"], + "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stt", "integration_type": "entity", diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 5852136ca45..9fae6ca9f73 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.5"] + "requirements": ["subarulink==0.7.6"] } diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 527ccc4069f..8a390ea2a27 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -40,48 +42,48 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="next_dawn", device_class=SensorDeviceClass.TIMESTAMP, - name="Next dawn", + translation_key="next_dawn", icon="mdi:sun-clock", value_fn=lambda data: data.next_dawn, ), SunSensorEntityDescription( key="next_dusk", device_class=SensorDeviceClass.TIMESTAMP, - name="Next dusk", + translation_key="next_dusk", icon="mdi:sun-clock", value_fn=lambda data: data.next_dusk, ), SunSensorEntityDescription( key="next_midnight", device_class=SensorDeviceClass.TIMESTAMP, - name="Next midnight", + translation_key="next_midnight", icon="mdi:sun-clock", value_fn=lambda data: data.next_midnight, ), SunSensorEntityDescription( key="next_noon", device_class=SensorDeviceClass.TIMESTAMP, - name="Next noon", + translation_key="next_noon", icon="mdi:sun-clock", value_fn=lambda data: data.next_noon, ), SunSensorEntityDescription( key="next_rising", device_class=SensorDeviceClass.TIMESTAMP, - name="Next rising", + translation_key="next_rising", icon="mdi:sun-clock", value_fn=lambda data: data.next_rising, ), SunSensorEntityDescription( key="next_setting", device_class=SensorDeviceClass.TIMESTAMP, - name="Next setting", + translation_key="next_setting", icon="mdi:sun-clock", value_fn=lambda data: data.next_setting, ), SunSensorEntityDescription( key="solar_elevation", - name="Solar elevation", + translation_key="solar_elevation", icon="mdi:theme-light-dark", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.solar_elevation, @@ -90,7 +92,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( ), SunSensorEntityDescription( key="solar_azimuth", - name="Solar azimuth", + translation_key="solar_azimuth", icon="mdi:sun-angle", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.solar_azimuth, @@ -115,6 +117,7 @@ async def async_setup_entry( class SunSensor(SensorEntity): """Representation of a Sun Sensor.""" + _attr_has_entity_name = True entity_description: SunSensorEntityDescription def __init__( @@ -126,6 +129,12 @@ class SunSensor(SensorEntity): self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index 9a49a061c1f..3d0374f1de0 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -18,5 +18,17 @@ "below_horizon": "Below horizon" } } + }, + "entity": { + "sensor": { + "next_dawn": { "name": "Next dawn" }, + "next_dusk": { "name": "Next dusk" }, + "next_midnight": { "name": "Next midnight" }, + "next_noon": { "name": "Next noon" }, + "next_rising": { "name": "Next rising" }, + "next_setting": { "name": "Next setting" }, + "solar_azimuth": { "name": "Solar azimuth" }, + "solar_elevation": { "name": "Solar elevation" } + } } } diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 72956228948..541b31eb0a3 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -14,10 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -30,6 +27,7 @@ SCAN_INTERVAL = timedelta(seconds=10) SUPLA_FUNCTION_HA_CMP_MAP = { "CONTROLLINGTHEROLLERSHUTTER": Platform.COVER, "CONTROLLINGTHEGATE": Platform.COVER, + "CONTROLLINGTHEGARAGEDOOR": Platform.COVER, "LIGHTSWITCH": Platform.SWITCH, } SUPLA_FUNCTION_NONE = "NONE" @@ -154,58 +152,3 @@ async def discover_devices(hass, hass_config): # Load discovered devices for component_name, config in component_configs.items(): await async_load_platform(hass, component_name, DOMAIN, config, hass_config) - - -class SuplaChannel(CoordinatorEntity): - """Base class of a Supla Channel (an equivalent of HA's Entity).""" - - def __init__(self, config, server, coordinator): - """Init from config, hookup[ server and coordinator.""" - super().__init__(coordinator) - self.server_name = config["server_name"] - self.channel_id = config["channel_id"] - self.server = server - - @property - def channel_data(self): - """Return channel data taken from coordinator.""" - return self.coordinator.data.get(self.channel_id) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "supla-{}-{}".format( - self.channel_data["iodevice"]["gUIDString"].lower(), - self.channel_data["channelNumber"], - ) - - @property - def name(self) -> str | None: - """Return the name of the device.""" - return self.channel_data["caption"] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.channel_data is None: - return False - if (state := self.channel_data.get("state")) is None: - return False - return state.get("connected") - - async def async_action(self, action, **add_pars): - """Run server action. - - Actions are currently hardcoded in components. - Supla's API enables autodiscovery - """ - _LOGGER.debug( - "Executing action %s on channel %d, params: %s", - action, - self.channel_data["id"], - add_pars, - ) - await self.server.execute_action(self.channel_data["id"], action, **add_pars) - - # Update state - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index c6c1d9c07db..53e57fe1854 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -10,12 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS, SuplaChannel +from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS +from .entity import SuplaEntity _LOGGER = logging.getLogger(__name__) SUPLA_SHUTTER = "CONTROLLINGTHEROLLERSHUTTER" SUPLA_GATE = "CONTROLLINGTHEGATE" +SUPLA_GARAGE_DOOR = "CONTROLLINGTHEGARAGEDOOR" async def async_setup_platform( @@ -37,16 +39,16 @@ async def async_setup_platform( if device_name == SUPLA_SHUTTER: entities.append( - SuplaCover( + SuplaCoverEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], ) ) - elif device_name == SUPLA_GATE: + elif device_name in {SUPLA_GATE, SUPLA_GARAGE_DOOR}: entities.append( - SuplaGateDoor( + SuplaDoorEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], @@ -56,7 +58,7 @@ async def async_setup_platform( async_add_entities(entities) -class SuplaCover(SuplaChannel, CoverEntity): +class SuplaCoverEntity(SuplaEntity, CoverEntity): """Representation of a Supla Cover.""" @property @@ -90,33 +92,33 @@ class SuplaCover(SuplaChannel, CoverEntity): await self.async_action("STOP") -class SuplaGateDoor(SuplaChannel, CoverEntity): - """Representation of a Supla gate door.""" +class SuplaDoorEntity(SuplaEntity, CoverEntity): + """Representation of a Supla door.""" @property def is_closed(self) -> bool | None: - """Return if the gate is closed or not.""" + """Return if the door is closed or not.""" state = self.channel_data.get("state") if state and "hi" in state: return state.get("hi") return None async def async_open_cover(self, **kwargs: Any) -> None: - """Open the gate.""" + """Open the door.""" if self.is_closed: await self.async_action("OPEN_CLOSE") async def async_close_cover(self, **kwargs: Any) -> None: - """Close the gate.""" + """Close the door.""" if not self.is_closed: await self.async_action("OPEN_CLOSE") async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the gate.""" + """Stop the door.""" await self.async_action("OPEN_CLOSE") async def async_toggle(self, **kwargs: Any) -> None: - """Toggle the gate.""" + """Toggle the door.""" await self.async_action("OPEN_CLOSE") @property diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py new file mode 100644 index 00000000000..ae0a627b538 --- /dev/null +++ b/homeassistant/components/supla/entity.py @@ -0,0 +1,63 @@ +"""Base class for Supla channels.""" +from __future__ import annotations + +import logging + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +_LOGGER = logging.getLogger(__name__) + + +class SuplaEntity(CoordinatorEntity): + """Base class of a Supla Channel (an equivalent of HA's Entity).""" + + def __init__(self, config, server, coordinator): + """Init from config, hookup[ server and coordinator.""" + super().__init__(coordinator) + self.server_name = config["server_name"] + self.channel_id = config["channel_id"] + self.server = server + + @property + def channel_data(self): + """Return channel data taken from coordinator.""" + return self.coordinator.data.get(self.channel_id) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "supla-{}-{}".format( + self.channel_data["iodevice"]["gUIDString"].lower(), + self.channel_data["channelNumber"], + ) + + @property + def name(self) -> str | None: + """Return the name of the device.""" + return self.channel_data["caption"] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.channel_data is None: + return False + if (state := self.channel_data.get("state")) is None: + return False + return state.get("connected") + + async def async_action(self, action, **add_pars): + """Run server action. + + Actions are currently hardcoded in components. + Supla's API enables autodiscovery + """ + _LOGGER.debug( + "Executing action %s on channel %d, params: %s", + action, + self.channel_data["id"], + add_pars, + ) + await self.server.execute_action(self.channel_data["id"], action, **add_pars) + + # Update state + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index 9c4c53c1e9f..b270f4300e1 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -10,7 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS, SuplaChannel +from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS +from .entity import SuplaEntity _LOGGER = logging.getLogger(__name__) @@ -32,7 +33,7 @@ async def async_setup_platform( server_name = device["server_name"] entities.append( - SuplaSwitch( + SuplaSwitchEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], @@ -42,7 +43,7 @@ async def async_setup_platform( async_add_entities(entities) -class SuplaSwitch(SuplaChannel, SwitchEntity): +class SuplaSwitchEntity(SuplaEntity, SwitchEntity): """Representation of a Supla Switch.""" async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 8735726f892..12007e1741c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -35,7 +35,6 @@ CONF_START = "from" DEFAULT_NAME = "Next Departure" -ICON = "mdi:bus" SCAN_INTERVAL = timedelta(seconds=90) @@ -79,6 +78,7 @@ class SwissPublicTransportSensor(SensorEntity): """Implementation of an Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" + _attr_icon = "mdi:bus" def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" @@ -125,11 +125,6 @@ class SwissPublicTransportSensor(SensorEntity): ATTR_DELAY: self._opendata.connections[0]["delay"], } - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - async def async_update(self) -> None: """Get the latest data from opendata.ch and update the states.""" diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 675f3128721..00b22d9428c 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -20,50 +20,50 @@ PARALLEL_UPDATES = 0 BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { "calibration": BinarySensorEntityDescription( key="calibration", - name="Calibration", + translation_key="calibration", entity_category=EntityCategory.DIAGNOSTIC, ), "motion_detected": BinarySensorEntityDescription( key="pir_state", - name="Motion detected", + translation_key="motion", device_class=BinarySensorDeviceClass.MOTION, ), "contact_open": BinarySensorEntityDescription( key="contact_open", - name="Door open", + translation_key="door_open", device_class=BinarySensorDeviceClass.DOOR, ), "contact_timeout": BinarySensorEntityDescription( key="contact_timeout", - name="Door timeout", + translation_key="door_timeout", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), "is_light": BinarySensorEntityDescription( key="is_light", - name="Light", + translation_key="light", device_class=BinarySensorDeviceClass.LIGHT, ), "door_open": BinarySensorEntityDescription( key="door_status", - name="Door status", + translation_key="door_open", device_class=BinarySensorDeviceClass.DOOR, ), "unclosed_alarm": BinarySensorEntityDescription( key="unclosed_alarm", - name="Door unclosed alarm", + translation_key="door_unclosed_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), "unlocked_alarm": BinarySensorEntityDescription( key="unlocked_alarm", - name="Door unlocked alarm", + translation_key="door_unclosed_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), "auto_lock_paused": BinarySensorEntityDescription( key="auto_lock_paused", - name="Door auto-lock paused", + translation_key="door_auto_lock_paused", entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 3ed43790b83..b5b34bf54ec 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 SENSOR_TYPES: dict[str, SensorEntityDescription] = { "rssi": SensorEntityDescription( key="rssi", - name="Bluetooth signal strength", + translation_key="bluetooth_signal", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -37,7 +37,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "wifi_rssi": SensorEntityDescription( key="wifi_rssi", - name="Wi-Fi signal strength", + translation_key="wifi_signal", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -46,7 +46,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "battery": SensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, @@ -54,27 +54,27 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "lightLevel": SensorEntityDescription( key="lightLevel", - name="Light level", + translation_key="light_level", native_unit_of_measurement="Level", state_class=SensorStateClass.MEASUREMENT, ), "humidity": SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), "temperature": SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, ), "power": SensorEntityDescription( key="power", - name="Power", + translation_key="power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 3262ffbd7bb..74226583420 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -58,5 +58,56 @@ } } } + }, + "entity": { + "binary_sensor": { + "calibration": { + "name": "Calibration" + }, + "motion": { + "name": "[%key:component::binary_sensor::entity_component::motion::name%]" + }, + "door_open": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" + }, + "door_timeout": { + "name": "Timeout" + }, + "light": { + "name": "[%key:component::binary_sensor::entity_component::light::name%]" + }, + "door_unclosed_alarm": { + "name": "Unclosed alarm" + }, + "door_unlocked_alarm": { + "name": "Unlocked alarm" + }, + "door_auto_lock_paused": { + "name": "Auto-lock paused" + } + }, + "sensor": { + "bluetooth_signal": { + "name": "Bluetooth signal" + }, + "wifi_signal": { + "name": "Wi-Fi signal" + }, + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "light_level": { + "name": "Light level" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + } + } } } diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 9dc40d2dc53..1f335aee4b9 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -38,7 +38,7 @@ SECURITY_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = SynologyDSMBinarySensorEntityDescription( api_key=SynoCoreSecurity.API_KEY, key="status", - name="Security Status", + translation_key="status", device_class=BinarySensorDeviceClass.SAFETY, ), ) @@ -47,14 +47,14 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... SynologyDSMBinarySensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_exceed_bad_sector_thr", - name="Exceeded Max Bad Sectors", + translation_key="disk_exceed_bad_sector_thr", device_class=BinarySensorDeviceClass.SAFETY, entity_category=EntityCategory.DIAGNOSTIC, ), SynologyDSMBinarySensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_below_remain_life_thr", - name="Below Min Remaining Life", + translation_key="disk_below_remain_life_thr", device_class=BinarySensorDeviceClass.SAFETY, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 0cc17222802..0865686ef20 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -35,6 +35,7 @@ class SynologyDSMBaseEntity(CoordinatorEntity[_CoordinatorT]): entity_description: SynologyDSMEntityDescription unique_id: str _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -47,7 +48,6 @@ class SynologyDSMBaseEntity(CoordinatorEntity[_CoordinatorT]): self.entity_description = description self._api = api - self._attr_name = f"{api.network.hostname} {description.name}" self._attr_unique_id: str = ( f"{api.information.serial}_{description.api_key}:{description.key}" ) @@ -110,9 +110,6 @@ class SynologyDSMDeviceEntity( self._device_firmware = disk["firm"] self._device_type = disk["diskType"] - self._attr_name = ( - f"{self._api.network.hostname} ({self._device_name}) {description.name}" - ) self._attr_unique_id += f"_{self._device_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._api.information.serial}_{self._device_id}")}, diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 4031a921a7f..db0082daeab 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -50,7 +50,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_other_load", - name="CPU Utilization (Other)", + translation_key="cpu_other_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", entity_registry_enabled_default=False, @@ -59,7 +59,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_user_load", - name="CPU Utilization (User)", + translation_key="cpu_user_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", state_class=SensorStateClass.MEASUREMENT, @@ -67,7 +67,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_system_load", - name="CPU Utilization (System)", + translation_key="cpu_system_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", entity_registry_enabled_default=False, @@ -76,7 +76,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_total_load", - name="CPU Utilization (Total)", + translation_key="cpu_total_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", state_class=SensorStateClass.MEASUREMENT, @@ -84,7 +84,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_1min_load", - name="CPU Load Average (1 min)", + translation_key="cpu_1min_load", native_unit_of_measurement=ENTITY_UNIT_LOAD, icon="mdi:chip", entity_registry_enabled_default=False, @@ -92,21 +92,21 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_5min_load", - name="CPU Load Average (5 min)", + translation_key="cpu_5min_load", native_unit_of_measurement=ENTITY_UNIT_LOAD, icon="mdi:chip", ), SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_15min_load", - name="CPU Load Average (15 min)", + translation_key="cpu_15min_load", native_unit_of_measurement=ENTITY_UNIT_LOAD, icon="mdi:chip", ), SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_real_usage", - name="Memory Usage (Real)", + translation_key="memory_real_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -114,7 +114,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_size", - name="Memory Size", + translation_key="memory_size", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -124,7 +124,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_cached", - name="Memory Cached", + translation_key="memory_cached", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -134,7 +134,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_available_swap", - name="Memory Available (Swap)", + translation_key="memory_available_swap", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -143,7 +143,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_available_real", - name="Memory Available (Real)", + translation_key="memory_available_real", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -152,7 +152,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_total_swap", - name="Memory Total (Swap)", + translation_key="memory_total_swap", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -161,7 +161,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_total_real", - name="Memory Total (Real)", + translation_key="memory_total_real", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -170,7 +170,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="network_up", - name="Upload Throughput", + translation_key="network_up", native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -179,7 +179,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="network_down", - name="Download Throughput", + translation_key="network_down", native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -190,13 +190,13 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_status", - name="Status", + translation_key="volume_status", icon="mdi:checkbox-marked-circle-outline", ), SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_size_total", - name="Total Size", + translation_key="volume_size_total", native_unit_of_measurement=UnitOfInformation.TERABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", @@ -206,7 +206,7 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_size_used", - name="Used Space", + translation_key="volume_size_used", native_unit_of_measurement=UnitOfInformation.TERABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", @@ -215,14 +215,14 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_percentage_used", - name="Volume Used", + translation_key="volume_percentage_used", native_unit_of_measurement=PERCENTAGE, icon="mdi:chart-pie", ), SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_disk_temp_avg", - name="Average Disk Temp", + translation_key="volume_disk_temp_avg", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -230,7 +230,7 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_disk_temp_max", - name="Maximum Disk Temp", + translation_key="volume_disk_temp_max", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -241,7 +241,7 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_smart_status", - name="Status (Smart)", + translation_key="disk_smart_status", icon="mdi:checkbox-marked-circle-outline", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,14 +249,14 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_status", - name="Status", + translation_key="disk_status", icon="mdi:checkbox-marked-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, ), SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_temp", - name="Temperature", + translation_key="disk_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -268,7 +268,7 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -277,7 +277,7 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="uptime", - name="Last Boot", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f571b9c5326..bf18a9707a1 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -60,5 +60,51 @@ } } } + }, + "entity": { + "binary_sensor": { + "disk_below_remain_life_thr": { "name": "Below min remaining life" }, + "disk_exceed_bad_sector_thr": { "name": "Exceeded max bad sectors" }, + "status": { "name": "Security status" } + }, + "sensor": { + "cpu_15min_load": { "name": "CPU load average (15 min)" }, + "cpu_1min_load": { "name": "CPU load average (1 min)" }, + "cpu_5min_load": { "name": "CPU load average (5 min)" }, + "cpu_other_load": { "name": "CPU utilization (other)" }, + "cpu_system_load": { "name": "CPU utilization (system)" }, + "cpu_total_load": { "name": "CPU utilization (total)" }, + "cpu_user_load": { "name": "CPU utilization (user)" }, + "disk_smart_status": { "name": "Status (smart)" }, + "disk_status": { "name": "Status" }, + "disk_temp": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "memory_available_real": { "name": "Memory available (real)" }, + "memory_available_swap": { "name": "Memory available (swap)" }, + "memory_cached": { "name": "Memory cached" }, + "memory_real_usage": { "name": "Memory usage (real)" }, + "memory_size": { "name": "Memory size" }, + "memory_total_real": { "name": "Memory total (real)" }, + "memory_total_swap": { "name": "Memory total (swap)" }, + "network_down": { "name": "Download throughput" }, + "network_up": { "name": "Upload throughput" }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "uptime": { "name": "Last boot" }, + "volume_disk_temp_avg": { "name": "Average disk temp" }, + "volume_disk_temp_max": { "name": "Maximum disk temp" }, + "volume_percentage_used": { "name": "Volume used" }, + "volume_size_total": { "name": "Total size" }, + "volume_size_used": { "name": "Used space" }, + "volume_status": { "name": "Status" } + }, + "switch": { + "home_mode": { "name": "Home mode" } + }, + "update": { + "update": { "name": "DSM update" } + } } } diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index e44c578f4d2..208d299cc2e 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -33,7 +33,7 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( SynologyDSMSwitchEntityDescription( api_key=SynoSurveillanceStation.HOME_MODE_API_KEY, key="home_mode", - name="Home Mode", + translation_key="home_mode", icon="mdi:home-account", ), ) @@ -72,10 +72,6 @@ class SynoDSMSurveillanceHomeModeToggle( super().__init__(api, coordinator, description) self._version = version - self._attr_name = ( - f"{self._api.network.hostname} Surveillance Station {description.name}" - ) - @property def is_on(self) -> bool: """Return the state.""" diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index ec3ea52293c..c550b180553 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -30,7 +30,7 @@ UPDATE_ENTITIES: Final = [ SynologyDSMUpdateEntityEntityDescription( api_key=SynoCoreUpgrade.API_KEY, key="update", - name="DSM Update", + translation_key="update", entity_category=EntityCategory.DIAGNOSTIC, ) ] diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index cd46b4fd957..8a5f53d52de 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -106,8 +106,11 @@ def _safe_get_message(record: logging.LogRecord) -> str: """ try: return record.getMessage() - except Exception: # pylint: disable=broad-except - return f"Bad logger message: {record.msg} ({record.args})" + except Exception as ex: # pylint: disable=broad-except + try: + return f"Bad logger message: {record.msg} ({record.args})" + except Exception: # pylint: disable=broad-except + return f"Bad logger message: {ex}" class LogEntry: diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 090835103f9..363c28cc3f8 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -59,7 +59,7 @@ class TagIDManager(collection.IDManager): return suggestion -class TagStorageCollection(collection.StorageCollection): +class TagStorageCollection(collection.DictStorageCollection): """Tag collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -80,9 +80,9 @@ class TagStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[TAG_ID] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {**data, **self.UPDATE_SCHEMA(update_data)} + data = {**item, **self.UPDATE_SCHEMA(update_data)} # make last_scanned JSON serializeable if LAST_SCANNED in update_data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() @@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = TagIDManager() hass.data[DOMAIN][TAGS] = storage_collection = TagStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index d9f417b2c8d..bfa6d01032b 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -159,8 +159,16 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): self._removed_from_hass = False await super().async_added_to_hass() - async def discovery_callback(config: TasmotaEntityConfig) -> None: - """Handle discovery update.""" + @callback + def discovery_callback(config: TasmotaEntityConfig) -> None: + """Handle discovery update. + + If the config has changed we will create a task to + do the discovery update. + + As this callback can fire when nothing has changed, this + is a normal function to avoid task creation until it is needed. + """ _LOGGER.debug( "Got update for entity with hash: %s '%s'", self._discovery_hash, @@ -169,7 +177,7 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): if not self._tasmota_entity.config_same(config): # Changed payload: Notify component _LOGGER.debug("Updating component: %s", self.entity_id) - await self.discovery_update(config) + self.hass.async_create_task(self.discovery_update(config)) else: # Unchanged payload: Ignore to avoid changing states _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90cd..786ea55b34f 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a8..aca0d5e5d96 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -144,6 +144,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 62d962ee526..214d95c72e5 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -119,7 +119,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = TimerStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -163,7 +162,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class TimerStorageCollection(collection.StorageCollection): +class TimerStorageCollection(collection.DictStorageCollection): """Timer storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -180,9 +179,9 @@ class TimerStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {CONF_ID: data[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) + data = {CONF_ID: item[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) # make duration JSON serializeable if CONF_DURATION in update_data: data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION]) diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index dd94b4c11b7..7fe8630cc98 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -18,8 +18,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ICON = "mdi:bus-clock" - CONF_APP_ID = "app_id" CONF_APP_KEY = "app_key" CONF_LINE = "line" @@ -74,6 +72,7 @@ class TMBSensor(SensorEntity): """Implementation of a TMB line/stop Sensor.""" _attr_attribution = "Data provided by Transport Metropolitans de Barcelona" + _attr_icon = "mdi:bus-clock" def __init__(self, ibus_client, stop, line, name): """Initialize the sensor.""" @@ -89,11 +88,6 @@ class TMBSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index c98bd0fa020..4b376941344 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -76,7 +76,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): SENSOR_TYPE = BinarySensorEntityDescription( key=ATTR_BATTERY_CHARGING, - name="Tracker battery charging", + translation_key="tracker_battery_charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, ) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 0cb73723369..038461494d6 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -38,7 +38,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): _attr_has_entity_name = True _attr_icon = "mdi:paw" - _attr_name = "Tracker" + _attr_translation_key = "tracker" def __init__(self, user_id: str, item: Trackables) -> None: """Initialize tracker entity.""" diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index d9b92386bea..9c0f8f307ed 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -138,7 +138,7 @@ class TractiveActivitySensor(TractiveSensor): SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_BATTERY_LEVEL, - name="Tracker battery level", + translation_key="tracker_battery_level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_class=TractiveHardwareSensor, @@ -148,20 +148,19 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( # Currently, only state operational and not_reporting are used # More states are available by polling the data key=ATTR_TRACKER_STATE, - name="Tracker state", translation_key="tracker_state", entity_class=TractiveHardwareSensor, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_ACTIVE, - name="Minutes active", + translation_key="minutes_active", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, entity_class=TractiveActivitySensor, ), TractiveSensorEntityDescription( key=ATTR_DAILY_GOAL, - name="Daily goal", + translation_key="daily_goal", icon="mdi:flag-checkered", native_unit_of_measurement=UnitOfTime.MINUTES, entity_class=TractiveActivitySensor, diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index f63f0cdf0af..d5aee51ed61 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -19,8 +19,28 @@ } }, "entity": { + "binary_sensor": { + "tracker_battery_charging": { + "name": "Tracker battery charging" + } + }, + "device_tracker": { + "tracker": { + "name": "Tracker" + } + }, "sensor": { + "daily_goal": { + "name": "Daily goal" + }, + "minutes_active": { + "name": "Minutes active" + }, + "tracker_battery_level": { + "name": "Tracker battery" + }, "tracker_state": { + "name": "Tracker state", "state": { "not_reporting": "Not reporting", "operational": "Operational", @@ -28,6 +48,17 @@ "system_startup": "System startup" } } + }, + "switch": { + "tracker_buzzer": { + "name": "Tracker buzzer" + }, + "tracker_led": { + "name": "Tracker LED" + }, + "live_tracking": { + "name": "Live tracking" + } } } } diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index a7f4a273bbe..7ae480d4f98 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -47,21 +47,21 @@ class TractiveSwitchEntityDescription( SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( TractiveSwitchEntityDescription( key=ATTR_BUZZER, - name="Tracker buzzer", + translation_key="tracker_buzzer", icon="mdi:volume-high", method="async_set_buzzer", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LED, - name="Tracker LED", + translation_key="tracker_led", icon="mdi:led-on", method="async_set_led", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LIVE_TRACKING, - name="Live tracking", + translation_key="live_tracking", icon="mdi:map-marker-path", method="async_set_live_tracking", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index aa8864ad23d..c1a827c27bb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -59,6 +59,7 @@ ATTR_LANGUAGE = "language" ATTR_MESSAGE = "message" ATTR_OPTIONS = "options" ATTR_PLATFORM = "platform" +ATTR_AUDIO_OUTPUT = "audio_output" BASE_URL_KEY = "tts_base_url" @@ -134,6 +135,45 @@ class TTSCache(TypedDict): filename: str voice: bytes + pending: asyncio.Task | None + + +@callback +def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: + """Resolve engine. + + Returns None if no engines found or invalid engine passed in. + """ + manager: SpeechManager = hass.data[DOMAIN] + + if engine is not None: + if engine not in manager.providers: + return None + return engine + + if not manager.providers: + return None + + if "cloud" in manager.providers: + return "cloud" + + return next(iter(manager.providers)) + + +async def async_support_options( + hass: HomeAssistant, + engine: str, + language: str | None = None, + options: dict | None = None, +) -> bool: + """Return if an engine supports options.""" + manager: SpeechManager = hass.data[DOMAIN] + try: + manager.process_options(engine, language, options) + except HomeAssistantError: + return False + + return True async def async_get_media_source_audio( @@ -457,8 +497,11 @@ class SpeechManager: ) extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - data = self.mem_cache[cache_key]["voice"] - return extension, data + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return extension, cached["voice"] @callback def _generate_cache_key( @@ -489,30 +532,62 @@ class SpeechManager: This method is a coroutine. """ provider = self.providers[engine] - extension, data = await provider.async_get_tts_audio(message, language, options) - if data is None or extension is None: - raise HomeAssistantError(f"No TTS from {engine} for '{message}'") + if options is not None and ATTR_AUDIO_OUTPUT in options: + expected_extension = options[ATTR_AUDIO_OUTPUT] + else: + expected_extension = None - # Create file infos - filename = f"{cache_key}.{extension}".lower() - - # Validate filename - if not _RE_VOICE_FILE.match(filename): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine} is invalid!" + async def get_tts_data() -> str: + """Handle data available.""" + extension, data = await provider.async_get_tts_audio( + message, language, options ) - # Save to memory - if extension == "mp3": - data = self.write_tags(filename, data, provider, message, language, options) - self._async_store_to_memcache(cache_key, filename, data) + if data is None or extension is None: + raise HomeAssistantError(f"No TTS from {engine} for '{message}'") - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) + # Create file infos + filename = f"{cache_key}.{extension}".lower() + # Validate filename + if not _RE_VOICE_FILE.match(filename): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine} is invalid!" + ) + + # Save to memory + if extension == "mp3": + data = self.write_tags( + filename, data, provider, message, language, options + ) + self._async_store_to_memcache(cache_key, filename, data) + + if cache: + self.hass.async_create_task( + self._async_save_tts_audio(cache_key, filename, data) + ) + + return filename + + audio_task = self.hass.async_create_task(get_tts_data()) + + if expected_extension is None: + return await audio_task + + def handle_error(_future: asyncio.Future) -> None: + """Handle error.""" + if audio_task.exception(): + self.mem_cache.pop(cache_key, None) + + audio_task.add_done_callback(handle_error) + + filename = f"{cache_key}.{expected_extension}".lower() + self.mem_cache[cache_key] = { + "filename": filename, + "voice": b"", + "pending": audio_task, + } return filename async def _async_save_tts_audio( @@ -563,7 +638,11 @@ class SpeechManager: self, cache_key: str, filename: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" - self.mem_cache[cache_key] = {"filename": filename, "voice": data} + self.mem_cache[cache_key] = { + "filename": filename, + "voice": data, + "pending": None, + } @callback def async_remove_from_mem() -> None: @@ -590,7 +669,11 @@ class SpeechManager: await self._async_file_to_mem(cache_key) content, _ = mimetypes.guess_type(filename) - return content, self.mem_cache[cache_key]["voice"] + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return content, cached["voice"] @staticmethod def write_tags( diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index c197632c11e..f52292e8096 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -40,16 +40,12 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" + from . import async_resolve_engine # pylint: disable=import-outside-toplevel + manager: SpeechManager = hass.data[DOMAIN] - if engine is not None: - pass - elif not manager.providers: - raise HomeAssistantError("No TTS providers available") - elif "cloud" in manager.providers: - engine = "cloud" - else: - engine = next(iter(manager.providers)) + if (engine := async_resolve_engine(hass, engine)) is None: + raise HomeAssistantError("Invalid TTS provider selected") manager.process_options(engine, language, options) params = { diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index d4e885b295e..d283b668995 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -313,6 +313,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): f"{wlan.name}{wlan.name_combine_suffix}" for wlan in self.controller.api.wlans.values() if not wlan.name_combine_enabled + and wlan.name_combine_suffix is not None } | { wlan["name"] diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index eb4a9b7afe4..1ffb8cfd946 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -15,6 +15,12 @@ }, { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:2" + }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:2" } ] } diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 17d6f679cf0..3a05da085d8 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -101,7 +101,7 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo ) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class UsbServiceInfo(BaseServiceInfo): """Prepared info from usb entries.""" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index dad2d8dfaf3..b7be0bdcfc0 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -35,7 +35,7 @@ from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, ) -from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -94,7 +94,6 @@ DEVICE_CLASS_MAP = { UnitOfEnergy.KILO_WATT_HOUR: SensorDeviceClass.ENERGY, } -ICON = "mdi:counter" PRECISION = 3 PAUSED = "paused" @@ -323,6 +322,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" + _attr_icon = "mdi:counter" _attr_should_poll = False def __init__( @@ -410,8 +410,11 @@ class UtilityMeterSensor(RestoreSensor): if (old_state_val := self._validate_state(old_state)) is not None: return new_state_val - old_state_val + _LOGGER.warning( - "Invalid state (%s > %s)", + "%s received an invalid state change coming from %s (%s > %s)", + self.name, + self._sensor_source_id, old_state.state if old_state else None, new_state_val, ) @@ -423,8 +426,14 @@ class UtilityMeterSensor(RestoreSensor): old_state: State | None = event.data.get("old_state") new_state: State = event.data.get("new_state") # type: ignore[assignment] # a state change event always has a new state + # First check if the new_state is valid (see discussion in PR #88446) if (new_state_val := self._validate_state(new_state)) is None: - _LOGGER.warning("Invalid state %s", new_state.state) + _LOGGER.warning( + "%s received an invalid new state from %s : %s", + self.name, + self._sensor_source_id, + new_state.state, + ) return if self._state is None: @@ -597,7 +606,7 @@ class UtilityMeterSensor(RestoreSensor): self.hass, [self._sensor_source_id], self.async_reading ) - self.async_on_remove(async_at_start(self.hass, async_source_tracking)) + self.async_on_remove(async_at_started(self.hass, async_source_tracking)) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" @@ -659,11 +668,6 @@ class UtilityMeterSensor(RestoreSensor): return state_attr - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def extra_restore_state_data(self) -> UtilitySensorExtraStoredData: """Return sensor specific state data to be restored.""" diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 924eb921a74..4f3fcbf9c87 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vallox", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], - "requirements": ["vallox-websocket-api==3.0.0"] + "requirements": ["vallox-websocket-api==3.2.1"] } diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 118d04d3c1b..711f66ea033 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -32,7 +32,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -83,6 +82,7 @@ class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" _attr_attribution = "Data provided by Västtrafik" + _attr_icon = "mdi:train" def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" @@ -110,11 +110,6 @@ class VasttrafikDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 554b16877c7..b2b1cb31624 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -210,8 +210,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version config_entry.version = 2 - # update the entry - hass.config_entries.async_update_entry(config_entry) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 0cfd6ebb81c..9615404a9a6 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,18 +84,24 @@ class VerisureAlarm( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() await self._async_set_arm_state( "DISARMED", self.coordinator.verisure.disarm(code) ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_HOME", self.coordinator.verisure.arm_home(code) ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index d13005b265d..53646c1e435 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -188,9 +188,10 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt def disable_autolock(self) -> None: """Disable autolock on a doorlock.""" try: - self.coordinator.verisure.set_lock_config( + command = self.coordinator.verisure.set_autolock_enabled( self.serial_number, auto_lock_enabled=False ) + self.coordinator.verisure.request(command) LOGGER.debug("Disabling autolock on %s", self.serial_number) except VerisureError as ex: LOGGER.error("Could not disable autolock, %s", ex) @@ -198,9 +199,10 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt def enable_autolock(self) -> None: """Enable autolock on a doorlock.""" try: - self.coordinator.verisure.set_lock_config( + command = self.coordinator.verisure.set_autolock_enabled( self.serial_number, auto_lock_enabled=True ) + self.coordinator.verisure.request(command) LOGGER.debug("Enabling autolock on %s", self.serial_number) except VerisureError as ex: LOGGER.error("Could not enable autolock, %s", ex) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index f031e7a131f..418172975d8 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -1,7 +1,7 @@ { "domain": "vicare", "name": "Viessmann ViCare", - "codeowners": ["@oischinger"], + "codeowners": [], "config_flow": true, "dhcp": [ { @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.21.0"] + "requirements": ["PyViCare==2.25.0"] } diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py index 2ae169a28eb..4edeb1e6bcd 100644 --- a/homeassistant/components/voice_assistant/__init__.py +++ b/homeassistant/components/voice_assistant/__init__.py @@ -1,16 +1,90 @@ """The Voice Assistant integration.""" from __future__ import annotations -from homeassistant.core import HomeAssistant +from collections.abc import AsyncIterable + +from homeassistant.components import stt +from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .error import PipelineNotFound +from .pipeline import ( + PipelineEvent, + PipelineEventCallback, + PipelineEventType, + PipelineInput, + PipelineRun, + PipelineStage, + async_get_pipeline, + async_setup_pipeline_store, +) from .websocket_api import async_register_websocket_api +__all__ = ( + "DOMAIN", + "async_setup", + "async_pipeline_from_audio_stream", + "PipelineEvent", + "PipelineEventType", +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Voice Assistant integration.""" - hass.data[DOMAIN] = {} + await async_setup_pipeline_store(hass) async_register_websocket_api(hass) return True + + +async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + event_callback: PipelineEventCallback, + stt_metadata: stt.SpeechMetadata, + stt_stream: AsyncIterable[bytes], + language: str | None = None, + pipeline_id: str | None = None, + conversation_id: str | None = None, + context: Context | None = None, +) -> None: + """Create an audio pipeline from an audio stream.""" + if language is None: + language = hass.config.language + + # Temporary workaround for language codes + if language == "en": + language = "en-US" + + if stt_metadata.language == "": + stt_metadata.language = language + + if context is None: + context = Context() + + pipeline = await async_get_pipeline( + hass, + pipeline_id=pipeline_id, + language=language, + ) + if pipeline is None: + raise PipelineNotFound( + "pipeline_not_found", f"Pipeline {pipeline_id} not found" + ) + + pipeline_input = PipelineInput( + conversation_id=conversation_id, + stt_metadata=stt_metadata, + stt_stream=stt_stream, + run=PipelineRun( + hass, + context=context, + pipeline=pipeline, + start_stage=PipelineStage.STT, + end_stage=PipelineStage.TTS, + event_callback=event_callback, + ), + ) + + await pipeline_input.validate() + await pipeline_input.execute() diff --git a/homeassistant/components/voice_assistant/const.py b/homeassistant/components/voice_assistant/const.py index 86572fb459f..f3006c98169 100644 --- a/homeassistant/components/voice_assistant/const.py +++ b/homeassistant/components/voice_assistant/const.py @@ -1,3 +1,2 @@ """Constants for the Voice Assistant integration.""" DOMAIN = "voice_assistant" -DEFAULT_PIPELINE = "default" diff --git a/homeassistant/components/voice_assistant/error.py b/homeassistant/components/voice_assistant/error.py new file mode 100644 index 00000000000..2a52bf82c8e --- /dev/null +++ b/homeassistant/components/voice_assistant/error.py @@ -0,0 +1,30 @@ +"""Voice Assistant errors.""" + +from homeassistant.exceptions import HomeAssistantError + + +class PipelineError(HomeAssistantError): + """Base class for pipeline errors.""" + + def __init__(self, code: str, message: str) -> None: + """Set error message.""" + self.code = code + self.message = message + + super().__init__(f"Pipeline error code={code}, message={message}") + + +class PipelineNotFound(PipelineError): + """Unspecified pipeline picked.""" + + +class SpeechToTextError(PipelineError): + """Error in speech to text portion of pipeline.""" + + +class IntentRecognitionError(PipelineError): + """Error in intent recognition portion of pipeline.""" + + +class TextToSpeechError(PipelineError): + """Error in text to speech portion of pipeline.""" diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json index 644c49e9459..f4a17bf52e7 100644 --- a/homeassistant/components/voice_assistant/manifest.json +++ b/homeassistant/components/voice_assistant/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["conversation", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/voice_assistant", "iot_class": "local_push", - "quality_scale": "internal" + "quality_scale": "internal", + "requirements": ["webrtcvad==2.0.10"] } diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 806a603f5e5..26cc2d2d27e 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -7,63 +7,67 @@ from dataclasses import asdict, dataclass, field import logging from typing import Any +import voluptuous as vol + from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation, media_source, stt +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.util.dt import utcnow +from homeassistant.helpers.collection import ( + StorageCollection, + StorageCollectionWebsocket, +) +from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util, ulid as ulid_util from .const import DOMAIN - -DEFAULT_TIMEOUT = 30 # seconds +from .error import ( + IntentRecognitionError, + PipelineError, + SpeechToTextError, + TextToSpeechError, +) _LOGGER = logging.getLogger(__name__) +STORAGE_KEY = f"{DOMAIN}.pipelines" +STORAGE_VERSION = 1 -@callback -def async_get_pipeline( +STORAGE_FIELDS = { + vol.Required("conversation_engine"): str, + vol.Required("language"): str, + vol.Required("name"): str, + vol.Required("stt_engine"): str, + vol.Required("tts_engine"): str, +} + +SAVE_DELAY = 10 + + +async def async_get_pipeline( hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None ) -> Pipeline | None: """Get a pipeline by id or create one for a language.""" + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN] + if pipeline_id is not None: - return hass.data[DOMAIN].get(pipeline_id) + return pipeline_store.data.get(pipeline_id) # Construct a pipeline for the required/configured language language = language or hass.config.language - return Pipeline( - name=language, - language=language, - stt_engine=None, # first engine - conversation_engine=None, # first agent - tts_engine=None, # first engine + return await pipeline_store.async_create_item( + { + "name": language, + "language": language, + "stt_engine": None, # first engine + "conversation_engine": None, # first agent + "tts_engine": None, # first engine + } ) -class PipelineError(Exception): - """Base class for pipeline errors.""" - - def __init__(self, code: str, message: str) -> None: - """Set error message.""" - self.code = code - self.message = message - - super().__init__(f"Pipeline error code={code}, message={message}") - - -class SpeechToTextError(PipelineError): - """Error in speech to text portion of pipeline.""" - - -class IntentRecognitionError(PipelineError): - """Error in intent recognition portion of pipeline.""" - - -class TextToSpeechError(PipelineError): - """Error in text to speech portion of pipeline.""" - - class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" @@ -84,7 +88,7 @@ class PipelineEvent: type: PipelineEventType data: dict[str, Any] | None = None - timestamp: str = field(default_factory=lambda: utcnow().isoformat()) + timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat()) def as_dict(self) -> dict[str, Any]: """Return a dict representation of the event.""" @@ -95,16 +99,32 @@ class PipelineEvent: } -@dataclass +PipelineEventCallback = Callable[[PipelineEvent], None] + + +@dataclass(frozen=True) class Pipeline: """A voice assistant pipeline.""" - name: str - language: str | None - stt_engine: str | None conversation_engine: str | None + language: str | None + name: str + stt_engine: str | None tts_engine: str | None + id: str = field(default_factory=ulid_util.ulid) + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return { + "conversation_engine": self.conversation_engine, + "id": self.id, + "language": self.language, + "name": self.name, + "stt_engine": self.stt_engine, + "tts_engine": self.tts_engine, + } + class PipelineStage(StrEnum): """Stages of a pipeline.""" @@ -148,9 +168,12 @@ class PipelineRun: pipeline: Pipeline start_stage: PipelineStage end_stage: PipelineStage - event_callback: Callable[[PipelineEvent], None] + event_callback: PipelineEventCallback language: str = None # type: ignore[assignment] runner_data: Any | None = None + stt_provider: stt.Provider | None = None + intent_agent: str | None = None + tts_engine: str | None = None def __post_init__(self): """Set language for pipeline.""" @@ -181,13 +204,39 @@ class PipelineRun: ) ) + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: + """Prepare speech to text.""" + stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + + if stt_provider is None: + engine = self.pipeline.stt_engine or "default" + raise SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=( + f"Provider {stt_provider.name} does not support input speech " + "to text metadata" + ), + ) + + self.stt_provider = stt_provider + async def speech_to_text( self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes], ) -> str: """Run speech to text portion of pipeline. Returns the spoken text.""" - engine = self.pipeline.stt_engine or "default" + if self.stt_provider is None: + raise RuntimeError("Speech to text was not prepared") + + engine = self.stt_provider.name + self.event_callback( PipelineEvent( PipelineEventType.STT_START, @@ -198,28 +247,11 @@ class PipelineRun: ) ) - try: - # Load provider - stt_provider: stt.Provider = stt.async_get_provider( - self.hass, self.pipeline.stt_engine - ) - assert stt_provider is not None - except Exception as src_error: - _LOGGER.exception("No speech to text provider for %s", engine) - raise SpeechToTextError( - code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", - ) from src_error - - if not stt_provider.check_metadata(metadata): - raise SpeechToTextError( - code="stt-provider-unsupported-metadata", - message=f"Provider {engine} does not support input speech to text metadata", - ) - try: # Transcribe audio stream - result = await stt_provider.async_process_audio_stream(metadata, stream) + result = await self.stt_provider.async_process_audio_stream( + metadata, stream + ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech to text") raise SpeechToTextError( @@ -253,15 +285,33 @@ class PipelineRun: return result.text + async def prepare_recognize_intent(self) -> None: + """Prepare recognizing an intent.""" + agent_info = conversation.async_get_agent_info( + self.hass, self.pipeline.conversation_engine + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) + + self.intent_agent = agent_info["id"] + async def recognize_intent( self, intent_input: str, conversation_id: str | None ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" + if self.intent_agent is None: + raise RuntimeError("Recognize intent was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, { - "engine": self.pipeline.conversation_engine or "default", + "engine": self.intent_agent, "intent_input": intent_input, }, ) @@ -274,7 +324,7 @@ class PipelineRun: conversation_id=conversation_id, context=self.context, language=self.language, - agent_id=self.pipeline.conversation_engine, + agent_id=self.intent_agent, ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") @@ -296,13 +346,38 @@ class PipelineRun: return speech + async def prepare_text_to_speech(self) -> None: + """Prepare text to speech.""" + engine = tts.async_resolve_engine(self.hass, self.pipeline.tts_engine) + + if engine is None: + engine = self.pipeline.tts_engine or "default" + raise TextToSpeechError( + code="tts-not-supported", + message=f"Text to speech engine '{engine}' not found", + ) + + if not await tts.async_support_options(self.hass, engine, self.language): + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text to speech engine {engine} " + f"does not support language {self.language}" + ), + ) + + self.tts_engine = engine + async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + if self.tts_engine is None: + raise RuntimeError("Text to speech was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.pipeline.tts_engine or "default", + "engine": self.tts_engine, "tts_input": tts_input, }, ) @@ -315,7 +390,8 @@ class PipelineRun: tts_generate_media_source_id( self.hass, tts_input, - engine=self.pipeline.tts_engine, + engine=self.tts_engine, + language=self.language, ), ) except Exception as src_error: @@ -341,6 +417,8 @@ class PipelineRun: class PipelineInput: """Input to a pipeline run.""" + run: PipelineRun + stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -355,21 +433,10 @@ class PipelineInput: conversation_id: str | None = None - async def execute( - self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT - ): - """Run pipeline with optional timeout.""" - await asyncio.wait_for( - self._execute(run), - timeout=timeout, - ) - - async def _execute(self, run: PipelineRun): - self._validate(run.start_stage) - - # stt -> intent -> tts - run.start() - current_stage = run.start_stage + async def execute(self): + """Run pipeline.""" + self.run.start() + current_stage = self.run.start_stage try: # Speech to text @@ -377,29 +444,29 @@ class PipelineInput: if current_stage == PipelineStage.STT: assert self.stt_metadata is not None assert self.stt_stream is not None - intent_input = await run.speech_to_text( + intent_input = await self.run.speech_to_text( self.stt_metadata, self.stt_stream, ) current_stage = PipelineStage.INTENT - if run.end_stage != PipelineStage.STT: + if self.run.end_stage != PipelineStage.STT: tts_input = self.tts_input if current_stage == PipelineStage.INTENT: assert intent_input is not None - tts_input = await run.recognize_intent( + tts_input = await self.run.recognize_intent( intent_input, self.conversation_id ) current_stage = PipelineStage.TTS - if run.end_stage != PipelineStage.INTENT: + if self.run.end_stage != PipelineStage.INTENT: if current_stage == PipelineStage.TTS: assert tts_input is not None - await run.text_to_speech(tts_input) + await self.run.text_to_speech(tts_input) except PipelineError as err: - run.event_callback( + self.run.event_callback( PipelineEvent( PipelineEventType.ERROR, {"code": err.code, "message": err.message}, @@ -407,11 +474,11 @@ class PipelineInput: ) return - run.end() + self.run.end() - def _validate(self, stage: PipelineStage): + async def validate(self): """Validate pipeline input against start stage.""" - if stage == PipelineStage.STT: + if self.run.start_stage == PipelineStage.STT: if self.stt_metadata is None: raise PipelineRunValidationError( "stt_metadata is required for speech to text" @@ -421,13 +488,73 @@ class PipelineInput: raise PipelineRunValidationError( "stt_stream is required for speech to text" ) - elif stage == PipelineStage.INTENT: + elif self.run.start_stage == PipelineStage.INTENT: if self.intent_input is None: raise PipelineRunValidationError( "intent_input is required for intent recognition" ) - elif stage == PipelineStage.TTS: + elif self.run.start_stage == PipelineStage.TTS: if self.tts_input is None: raise PipelineRunValidationError( "tts_input is required for text to speech" ) + + start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + + prepare_tasks = [] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + prepare_tasks.append(self.run.prepare_recognize_intent()) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + prepare_tasks.append(self.run.prepare_text_to_speech()) + + if prepare_tasks: + await asyncio.gather(*prepare_tasks) + + +class PipelineStorageCollection(StorageCollection[Pipeline]): + """Pipeline storage collection.""" + + CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + # We don't need to validate, the WS API has already validated + return data + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + return ulid_util.ulid() + + async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline: + """Return a new updated item.""" + return Pipeline(id=item.id, **update_data) + + def _create_item(self, item_id: str, data: dict) -> Pipeline: + """Create an item from validated config.""" + return Pipeline(id=item_id, **data) + + def _deserialize_item(self, data: dict) -> Pipeline: + """Create an item from its serialized representation.""" + return Pipeline(**data) + + def _serialize_item(self, item_id: str, item: Pipeline) -> dict: + """Return the serialized representation of an item.""" + return item.to_json() + + +async def async_setup_pipeline_store(hass): + """Set up the pipeline storage collection.""" + pipeline_store = PipelineStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY) + ) + await pipeline_store.async_load() + StorageCollectionWebsocket( + pipeline_store, f"{DOMAIN}/pipeline", "pipeline", STORAGE_FIELDS, STORAGE_FIELDS + ).async_setup(hass) + hass.data[DOMAIN] = pipeline_store diff --git a/homeassistant/components/voice_assistant/vad.py b/homeassistant/components/voice_assistant/vad.py new file mode 100644 index 00000000000..e86579b9750 --- /dev/null +++ b/homeassistant/components/voice_assistant/vad.py @@ -0,0 +1,128 @@ +"""Voice activity detection.""" +from dataclasses import dataclass, field + +import webrtcvad + +_SAMPLE_RATE = 16000 + + +@dataclass +class VoiceCommandSegmenter: + """Segments an audio stream into voice commands using webrtcvad.""" + + vad_mode: int = 3 + """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" + + vad_frames: int = 480 # 30 ms + """Must be 10, 20, or 30 ms at 16Khz.""" + + speech_seconds: float = 0.3 + """Seconds of speech before voice command has started.""" + + silence_seconds: float = 0.5 + """Seconds of silence after voice command has ended.""" + + timeout_seconds: float = 15.0 + """Maximum number of seconds before stopping with timeout=True.""" + + reset_seconds: float = 1.0 + """Seconds before reset start/stop time counters.""" + + _in_command: bool = False + """True if inside voice command.""" + + _speech_seconds_left: float = 0.0 + """Seconds left before considering voice command as started.""" + + _silence_seconds_left: float = 0.0 + """Seconds left before considering voice command as stopped.""" + + _timeout_seconds_left: float = 0.0 + """Seconds left before considering voice command timed out.""" + + _reset_seconds_left: float = 0.0 + """Seconds left before resetting start/stop time counters.""" + + _vad: webrtcvad.Vad = None + _audio_buffer: bytes = field(default_factory=bytes) + _bytes_per_chunk: int = 480 * 2 # 16-bit samples + _seconds_per_chunk: float = 0.03 # 30 ms + + def __post_init__(self): + """Initialize VAD.""" + self._vad = webrtcvad.Vad(self.vad_mode) + self._bytes_per_chunk = self.vad_frames * 2 + self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self.reset() + + def reset(self): + """Reset all counters and state.""" + self._audio_buffer = b"" + self._speech_seconds_left = self.speech_seconds + self._silence_seconds_left = self.silence_seconds + self._timeout_seconds_left = self.timeout_seconds + self._reset_seconds_left = self.reset_seconds + self._in_command = False + + def process(self, samples: bytes) -> bool: + """Process a 16-bit 16Khz mono audio samples. + + Returns False when command is done. + """ + self._audio_buffer += samples + + # Process in 10, 20, or 30 ms chunks. + num_chunks = len(self._audio_buffer) // self._bytes_per_chunk + for chunk_idx in range(num_chunks): + chunk_offset = chunk_idx * self._bytes_per_chunk + chunk = self._audio_buffer[ + chunk_offset : chunk_offset + self._bytes_per_chunk + ] + if not self._process_chunk(chunk): + self.reset() + return False + + if num_chunks > 0: + # Remove from buffer + self._audio_buffer = self._audio_buffer[ + num_chunks * self._bytes_per_chunk : + ] + + return True + + def _process_chunk(self, chunk: bytes) -> bool: + """Process a single chunk of 16-bit 16Khz mono audio. + + Returns False when command is done. + """ + is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) + + self._timeout_seconds_left -= self._seconds_per_chunk + if self._timeout_seconds_left <= 0: + return False + + if not self._in_command: + if is_speech: + self._reset_seconds_left = self.reset_seconds + self._speech_seconds_left -= self._seconds_per_chunk + if self._speech_seconds_left <= 0: + # Inside voice command + self._in_command = True + else: + # Reset if enough silence + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._speech_seconds_left = self.speech_seconds + else: + if not is_speech: + self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + return False + else: + # Reset if enough speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + + return True diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 28cafb7a355..42c22bfbed5 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -1,17 +1,17 @@ """Voice Assistant Websocket API.""" import asyncio -import audioop +import audioop # pylint: disable=deprecated-module from collections.abc import Callable import logging from typing import Any +import async_timeout import voluptuous as vol from homeassistant.components import stt, websocket_api from homeassistant.core import HomeAssistant, callback from .pipeline import ( - DEFAULT_TIMEOUT, PipelineError, PipelineEvent, PipelineEventType, @@ -20,13 +20,12 @@ from .pipeline import ( PipelineStage, async_get_pipeline, ) +from .vad import VoiceCommandSegmenter + +DEFAULT_TIMEOUT = 30 _LOGGER = logging.getLogger(__name__) -_VAD_ENERGY_THRESHOLD = 1000 -_VAD_SPEECH_FRAMES = 25 -_VAD_SILENCE_FRAMES = 25 - @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -34,17 +33,6 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) -def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: - """Compute RMS of debiased audio.""" - energy = -audioop.rms(audio_data, width) - energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) - debiased_energy = audioop.rms( - audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width - ) - - return debiased_energy - - @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", @@ -73,7 +61,7 @@ async def websocket_run( language = "en-US" pipeline_id = msg.get("pipeline") - pipeline = async_get_pipeline( + pipeline = await async_get_pipeline( hass, pipeline_id=pipeline_id, language=language, @@ -103,30 +91,14 @@ async def websocket_run( async def stt_stream(): state = None - speech_count = 0 - in_voice_command = False + segmenter = VoiceCommandSegmenter() # Yield until we receive an empty chunk while chunk := await audio_queue.get(): chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) - is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD - - if in_voice_command: - if is_speech: - speech_count += 1 - else: - speech_count -= 1 - - if speech_count <= -_VAD_SILENCE_FRAMES: - _LOGGER.info("Voice command stopped") - break - else: - if is_speech: - speech_count += 1 - - if speech_count >= _VAD_SPEECH_FRAMES: - in_voice_command = True - _LOGGER.info("Voice command started") + if not segmenter.process(chunk): + # Voice command is finished + break yield chunk @@ -155,37 +127,40 @@ async def websocket_run( # Input to text to speech system input_args["tts_input"] = msg["input"]["text"] - run_task = hass.async_create_task( - PipelineInput(**input_args).execute( - PipelineRun( - hass, - context=connection.context(msg), - pipeline=pipeline, - start_stage=start_stage, - end_stage=end_stage, - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() - ), - runner_data={ - "stt_binary_handler_id": handler_id, - }, - ), - timeout=timeout, - ) + input_args["run"] = PipelineRun( + hass, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, + event_callback=lambda event: connection.send_event(msg["id"], event.as_dict()), + runner_data={ + "stt_binary_handler_id": handler_id, + "timeout": timeout, + }, ) - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel + pipeline_input = PipelineInput(**input_args) + + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return # Confirm subscription connection.send_result(msg["id"]) + run_task = hass.async_create_task(pipeline_input.execute()) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + try: # Task contains a timeout - await run_task - except PipelineError as error: - # Report more specific error when possible - connection.send_error(msg["id"], error.code, error.message) + async with async_timeout.timeout(timeout): + await run_task except asyncio.TimeoutError: connection.send_event( msg["id"], diff --git a/homeassistant/components/xbox_live/__init__.py b/homeassistant/components/xbox_live/__init__.py deleted file mode 100644 index cc9e8ac3518..00000000000 --- a/homeassistant/components/xbox_live/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The xbox_live component.""" diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json deleted file mode 100644 index bf3e798da05..00000000000 --- a/homeassistant/components/xbox_live/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "xbox_live", - "name": "Xbox Live", - "codeowners": ["@MartinHjelmare"], - "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "iot_class": "cloud_polling", - "loggers": ["xboxapi"], - "requirements": ["xboxapi==2.0.1"] -} diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py deleted file mode 100644 index d95031a646e..00000000000 --- a/homeassistant/components/xbox_live/sensor.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Sensor for Xbox Live account status.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import voluptuous as vol -from xboxapi import Client - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_XUID = "xuid" - -ICON = "mdi:microsoft-xbox" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Xbox platform.""" - create_issue( - hass, - "xbox_live", - "pending_removal", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Xbox Live integration is deprecated " - "and will be removed in Home Assistant 2023.2" - ) - api = Client(api_key=config[CONF_API_KEY]) - entities = [] - - # request profile info to check api connection - response = api.api_get("profile") - if not response.ok: - _LOGGER.error( - ( - "Can't setup X API connection. Check your account or " - "api key on xapi.us. Code: %s Description: %s " - ), - response.status_code, - response.reason, - ) - return - - users = config[CONF_XUID] - - interval = timedelta(minutes=1 * len(users)) - interval = config.get(CONF_SCAN_INTERVAL, interval) - - for xuid in users: - if (gamercard := get_user_gamercard(api, xuid)) is None: - continue - entities.append(XboxSensor(api, xuid, gamercard, interval)) - - add_entities(entities, True) - - -def get_user_gamercard(api, xuid): - """Get profile info.""" - gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") - _LOGGER.debug("User gamercard: %s", gamercard) - - if gamercard.get("success", True) and gamercard.get("code") is None: - return gamercard - _LOGGER.error( - "Can't get user profile %s. Error Code: %s Description: %s", - xuid, - gamercard.get("code", "unknown"), - gamercard.get("description", "unknown"), - ) - return None - - -class XboxSensor(SensorEntity): - """A class for the Xbox account.""" - - _attr_should_poll = False - - def __init__(self, api, xuid, gamercard, interval): - """Initialize the sensor.""" - self._state = None - self._presence = [] - self._xuid = xuid - self._api = api - self._gamertag = gamercard["gamertag"] - self._gamerscore = gamercard["gamerscore"] - self._interval = interval - self._picture = gamercard["gamerpicSmallSslImagePath"] - self._tier = gamercard["tier"] - - @property - def name(self): - """Return the name of the sensor.""" - return self._gamertag - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = {"gamerscore": self._gamerscore, "tier": self._tier} - - for device in self._presence: - for title in device["titles"]: - attributes[f'{device["type"]} {title["placement"]}'] = title["name"] - - return attributes - - @property - def entity_picture(self): - """Avatar of the account.""" - return self._picture - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON - - async def async_added_to_hass(self) -> None: - """Start custom polling.""" - - @callback - def async_update(event_time=None): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async_track_time_interval(self.hass, async_update, self._interval) - - def update(self) -> None: - """Update state data from Xbox API.""" - presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") - _LOGGER.debug("User presence: %s", presence) - self._state = presence["state"] - self._presence = presence.get("devices", []) diff --git a/homeassistant/components/xbox_live/strings.json b/homeassistant/components/xbox_live/strings.json deleted file mode 100644 index 0f73f851bd7..00000000000 --- a/homeassistant/components/xbox_live/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Xbox Live integration is being removed", - "description": "The Xbox Live integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.2.\n\nThe integration is being removed, because it is only useful for the legacy device Xbox 360 and the upstream API now requires a paid subscription. Newer consoles are supported by the Xbox integration for free.\n\nRemove the Xbox Live YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index d78d2f72e36..4d5cddd9517 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.16.4"] + "requirements": ["xiaomi-ble==0.17.0"] } diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 58b85bc34ec..b6810cf4cf2 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -291,6 +291,7 @@ async def async_create_miio_device_and_coordinator( name = entry.title device: MiioDevice | None = None migrate = False + lazy_discover = False update_method = _async_update_data_default coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator @@ -307,38 +308,41 @@ async def async_create_miio_device_and_coordinator( # Humidifiers if model in MODELS_HUMIDIFIER_MIOT: - device = AirHumidifierMiot(host, token) + device = AirHumidifierMiot(host, token, lazy_discover=lazy_discover) migrate = True elif model in MODELS_HUMIDIFIER_MJJSQ: - device = AirHumidifierMjjsq(host, token, model=model) + device = AirHumidifierMjjsq( + host, token, lazy_discover=lazy_discover, model=model + ) migrate = True elif model in MODELS_HUMIDIFIER_MIIO: - device = AirHumidifier(host, token, model=model) + device = AirHumidifier(host, token, lazy_discover=lazy_discover, model=model) migrate = True # Airpurifiers and Airfresh elif model in MODELS_PURIFIER_MIOT: - device = AirPurifierMiot(host, token) + device = AirPurifierMiot(host, token, lazy_discover=lazy_discover) elif model.startswith("zhimi.airpurifier."): - device = AirPurifier(host, token) + device = AirPurifier(host, token, lazy_discover=lazy_discover) elif model.startswith("zhimi.airfresh."): - device = AirFresh(host, token) + device = AirFresh(host, token, lazy_discover=lazy_discover) elif model == MODEL_AIRFRESH_A1: - device = AirFreshA1(host, token) + device = AirFreshA1(host, token, lazy_discover=lazy_discover) elif model == MODEL_AIRFRESH_T2017: - device = AirFreshT2017(host, token) + device = AirFreshT2017(host, token, lazy_discover=lazy_discover) elif ( model in MODELS_VACUUM or model.startswith(ROBOROCK_GENERIC) or model.startswith(ROCKROBO_GENERIC) ): + # TODO: add lazy_discover as argument when python-miio add support # pylint: disable=fixme device = RoborockVacuum(host, token) update_method = _async_update_data_vacuum coordinator_class = DataUpdateCoordinator[VacuumCoordinatorData] # Pedestal fans elif model in MODEL_TO_CLASS_MAP: - device = MODEL_TO_CLASS_MAP[model](host, token) + device = MODEL_TO_CLASS_MAP[model](host, token, lazy_discover=lazy_discover) elif model in MODELS_FAN_MIIO: - device = Fan(host, token, model=model) + device = Fan(host, token, lazy_discover=lazy_discover, model=model) else: _LOGGER.error( ( diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6c..c2d1a2155c3 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index d3e7e48815c..1fbae6c88a6 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -29,7 +29,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE = "routes" DEFAULT_NAME = "Yandex Transport" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -70,6 +70,7 @@ class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" _attr_attribution = "Data provided by maps.yandex.ru" + _attr_icon = "mdi:bus" def __init__(self, requester: YandexMapsRequester, stop_id, routes, name) -> None: """Initialize sensor.""" @@ -168,8 +169,3 @@ class DiscoverYandexTransport(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc1242714..17fb4c5856d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -97,7 +97,7 @@ CONFIG_SCHEMA = vol.Schema( ) -@dataclass +@dataclass(slots=True) class ZeroconfServiceInfo(BaseServiceInfo): """Prepared info from mDNS entries.""" @@ -564,14 +564,20 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not (addresses := service.addresses or service.parsed_addresses()): + if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - if (host := _first_non_link_local_address(addresses)) is None: + host: str | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + host = str(ip_addr) + break + if not host: return None + assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( - host=str(host), - addresses=service.parsed_addresses(), + host=host, + addresses=[str(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +586,6 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: ) -def _first_non_link_local_address( - addresses: list[bytes] | list[str], -) -> str | None: - """Return the first ipv6 or non-link local ipv4 address, preferring IPv4.""" - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 4 - ): - return str(ip_addr) - # If we didn't find a good IPv4 address, check for IPv6 addresses. - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 6 - ): - return str(ip_addr) - return None - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46b..b967954849c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.4"] + "requirements": ["zeroconf==0.56.0"] } diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 3c6b7c7186d..9b520c46819 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Zestimate" NAME = "zestimate" ZESTIMATE = f"{DEFAULT_NAME}:{NAME}" -ICON = "mdi:home-variant" ATTR_AMOUNT = "amount" ATTR_CHANGE = "amount_change_30_days" @@ -67,6 +66,7 @@ class ZestimateDataSensor(SensorEntity): """Implementation of a Zestimate sensor.""" _attr_attribution = "Data provided by Zillow.com" + _attr_icon = "mdi:home-variant" def __init__(self, name, params): """Initialize the sensor.""" @@ -103,11 +103,6 @@ class ZestimateDataSensor(SensorEntity): attributes["address"] = self.address return attributes - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self): """Get the latest data and update the states.""" diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b277b3fe267..696216e3e81 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,18 @@ from __future__ import annotations import functools +from typing import Any + +import zigpy.types as t +from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -116,11 +121,21 @@ class Occupancy(BinarySensor): @STRICT_MATCH(channel_names=CHANNEL_ON_OFF) class Opening(BinarySensor): - """ZHA BinarySensor.""" + """ZHA OnOff BinarySensor.""" SENSOR_ATTR = "on_off" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING + # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. + # We need to manually restore the last state from the sensor state to the runtime cache for now. + @callback + def async_restore_last_state(self, last_state): + """Restore previous state to zigpy cache.""" + self._channel.cluster.update_attribute( + OnOff.attributes_by_name[self.SENSOR_ATTR].id, + t.Bool.true if last_state.state == STATE_ON else t.Bool.false, + ) + @MULTI_MATCH(channel_names=CHANNEL_BINARY_INPUT) class BinaryInput(BinarySensor): @@ -141,10 +156,9 @@ class BinaryInput(BinarySensor): manufacturers="Philips", models={"SML001", "SML002"}, ) -class Motion(BinarySensor): - """ZHA BinarySensor.""" +class Motion(Opening): + """ZHA OnOff BinarySensor with motion device class.""" - SENSOR_ATTR = "on_off" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION @@ -164,6 +178,36 @@ class IASZone(BinarySensor): """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) + @MULTI_MATCH( channel_names="tuya_manufacturer", diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd630..6d4899be37c 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index e312f398b54..b880a338a42 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -187,11 +187,16 @@ class SmartThingsAcceleration(ZigbeeChannel): @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" + try: + attr_name = self._cluster.attributes[attrid].name + except KeyError: + attr_name = UNKNOWN + if attrid == self.value_attribute: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self._cluster.attributes.get(attrid, [UNKNOWN])[0], + attr_name, value, ) return @@ -200,7 +205,7 @@ class SmartThingsAcceleration(ZigbeeChannel): SIGNAL_ATTR_UPDATED, { ATTR_ATTRIBUTE_ID: attrid, - ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, [UNKNOWN])[0], + ATTR_ATTRIBUTE_NAME: attr_name, ATTR_VALUE: value, }, ) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a2..6423723d326 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3f9ada1ed08..8858ea69590 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -172,6 +174,20 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + return app_controller_cls, app_controller_cls.SCHEMA(app_config) async def async_initialize(self) -> None: diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e1..966f35fe98b 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d82fe5ed0f8..7bc482681ca 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.95", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zha-quirks==0.0.96", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 3ab2a35bf1e..cad92a2978c 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -163,7 +163,7 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS]) -class ZoneStorageCollection(collection.StorageCollection): +class ZoneStorageCollection(collection.DictStorageCollection): """Zone collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -178,10 +178,10 @@ class ZoneStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - return {**data, **update_data} + return {**item, **update_data} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -198,7 +198,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = ZoneStorageCollection( storage.Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5fb7726577b..d41ee0272a9 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/homeassistant/config.py b/homeassistant/config.py index 283f8726e2b..0a5da91d942 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -61,7 +61,7 @@ from .helpers import ( ) from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType -from .loader import Integration, IntegrationNotFound +from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system @@ -681,7 +681,7 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> _LOGGER.error(message) -def _identify_config_schema(module: ModuleType) -> str | None: +def _identify_config_schema(module: ComponentProtocol) -> str | None: """Extract the schema and identify list or dict based.""" if not isinstance(module.CONFIG_SCHEMA, vol.Schema): return None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 454cfeade27..3731f5fa9ae 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -383,7 +383,7 @@ class ConfigEntry: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -546,8 +546,7 @@ class ConfigEntry: await self._async_process_on_unload() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception as ex: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -628,15 +627,14 @@ class ConfigEntry: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: # pylint: disable-next=protected-access hass.config_entries._async_schedule_save() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain diff --git a/homeassistant/core.py b/homeassistant/core.py index 78ceb620e53..a47ace48424 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -217,13 +217,25 @@ class HassJob(Generic[_P, _R_co]): we run the job. """ - __slots__ = ("job_type", "target", "name") + __slots__ = ("job_type", "target", "name", "_cancel_on_shutdown") - def __init__(self, target: Callable[_P, _R_co], name: str | None = None) -> None: + def __init__( + self, + target: Callable[_P, _R_co], + name: str | None = None, + *, + cancel_on_shutdown: bool | None = None, + ) -> None: """Create a job object.""" self.target = target self.name = name self.job_type = _get_hassjob_callable_job_type(target) + self._cancel_on_shutdown = cancel_on_shutdown + + @property + def cancel_on_shutdown(self) -> bool | None: + """Return if the job should be cancelled on shutdown.""" + return self._cancel_on_shutdown def __repr__(self) -> str: """Return the job.""" @@ -505,12 +517,14 @@ class HomeAssistant: return task - def create_task(self, target: Coroutine[Any, Any, Any]) -> None: + def create_task( + self, target: Coroutine[Any, Any, Any], name: str | None = None + ) -> None: """Add task to the executor pool. target: target to call. """ - self.loop.call_soon_threadsafe(self.async_create_task, target) + self.loop.call_soon_threadsafe(self.async_create_task, target, name) @callback def async_create_task( @@ -728,6 +742,7 @@ class HomeAssistant: self._tasks.add(task) task.add_done_callback(self._tasks.remove) task.cancel() + self._cancel_cancellable_timers() self.exit_code = exit_code @@ -812,6 +827,20 @@ class HomeAssistant: if self._stopped is not None: self._stopped.set() + def _cancel_cancellable_timers(self) -> None: + """Cancel timer handles marked as cancellable.""" + # pylint: disable-next=protected-access + handles: Iterable[asyncio.TimerHandle] = self.loop._scheduled # type: ignore[attr-defined] + for handle in handles: + if ( + not handle.cancelled() + and (args := handle._args) # pylint: disable=protected-access + # pylint: disable-next=unidiomatic-typecheck + and type(job := args[0]) is HassJob + and job.cancel_on_shutdown + ): + handle.cancel() + def _async_log_running_tasks(self, stage: int) -> None: """Log all running tasks.""" for task in self._tasks: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 347ab89e452..e213814f52c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -48,7 +48,7 @@ RESULT_TYPE_MENU = "menu" EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" -@dataclass +@dataclass(slots=True) class BaseServiceInfo: """Base class for discovery ServiceInfo.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2f84b0b10d2..08f43fa5b04 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -39,6 +39,7 @@ FLOWS = { "ambient_station", "android_ip_webcam", "androidtv", + "androidtv_remote", "anthemav", "apcupsd", "apple_tv", @@ -79,7 +80,6 @@ FLOWS = { "coinbase", "control4", "coolmaster", - "coronavirus", "cpuspeed", "crownstone", "daikin", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 333db76d4f3..4a496b93d84 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -330,6 +330,10 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "nuki", "hostname": "nuki_bridge_*", }, + { + "domain": "obihai", + "macaddress": "9CADEF*", + }, { "domain": "oncue", "hostname": "kohlergen*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 02273b8d97f..3f5ee393800 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -241,11 +241,17 @@ "iot_class": "local_polling" }, "androidtv": { - "name": "Android TV", + "name": "Android Debug Bridge", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, + "androidtv_remote": { + "name": "Android TV Remote", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "anel_pwrctrl": { "name": "Anel NET-PwrCtrl", "integration_type": "hub", @@ -875,12 +881,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "coronavirus": { - "name": "Coronavirus (COVID-19)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "cozytouch": { "name": "Atlantic Cozytouch", "integration_type": "virtual", @@ -926,12 +926,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "darksky": { - "name": "Dark Sky", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "datadog": { "name": "Datadog", "integration_type": "hub", @@ -3293,12 +3287,6 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" - }, - "xbox_live": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Xbox Live" } } }, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 3f26ec8fa78..3a2097a1d30 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -305,6 +305,12 @@ SSDP = { { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:2", }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:2", + }, ], "webostv": [ { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2f3dbaefb17..1771d9d63bf 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_androidtvremote2._tcp.local.": [ + { + "domain": "androidtv_remote", + }, + ], "_api._tcp.local.": [ { "domain": "baf", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 10ed9fdd65d..48746b339dc 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -216,7 +216,7 @@ class AreaRegistry: if not new_values: return old - new = self.areas[area_id] = attr.evolve(old, **new_values) + new = self.areas[area_id] = attr.evolve(old, **new_values) # type: ignore[arg-type] if normalized_name is not None: self._normalized_name_area_idx[ normalized_name diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd418719..4d5dc4012ee 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,7 +7,8 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging -from typing import Any, cast +from operator import attrgetter +from typing import Any, Generic, TypedDict, TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -31,6 +32,8 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" +_T = TypeVar("_T") + @dataclass class CollectionChangeSet: @@ -120,23 +123,20 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection(ABC): +class ObservableCollection(ABC, Generic[_T]): """Base collection type that can be observed.""" - def __init__( - self, logger: logging.Logger, id_manager: IDManager | None = None - ) -> None: + def __init__(self, id_manager: IDManager | None) -> None: """Initialize the base collection.""" - self.logger = logger self.id_manager = id_manager or IDManager() - self.data: dict[str, dict] = {} + self.data: dict[str, _T] = {} self.listeners: list[ChangeListener] = [] self.change_set_listeners: list[ChangeSetListener] = [] self.id_manager.add_collection(self.data) @callback - def async_items(self) -> list[dict]: + def async_items(self) -> list[_T]: """Return list of items in collection.""" return list(self.data.values()) @@ -171,9 +171,18 @@ class ObservableCollection(ABC): ) -class YamlCollection(ObservableCollection): +class YamlCollection(ObservableCollection[dict]): """Offer a collection based on static data.""" + def __init__( + self, + logger: logging.Logger, + id_manager: IDManager | None = None, + ) -> None: + """Initialize the storage collection.""" + super().__init__(id_manager) + self.logger = logger + @staticmethod def create_entity( entity_class: type[CollectionEntity], config: ConfigType @@ -211,17 +220,22 @@ class YamlCollection(ObservableCollection): await self.notify_changes(change_sets) -class StorageCollection(ObservableCollection, ABC): +class SerializedStorageCollection(TypedDict): + """Serialized storage collection.""" + + items: list[dict[str, Any]] + + +class StorageCollection(ObservableCollection[_T], ABC): """Offer a CRUD interface on top of JSON storage.""" def __init__( self, - store: Store, - logger: logging.Logger, + store: Store[SerializedStorageCollection], id_manager: IDManager | None = None, ) -> None: """Initialize the storage collection.""" - super().__init__(logger, id_manager) + super().__init__(id_manager) self.store = store @staticmethod @@ -236,9 +250,9 @@ class StorageCollection(ObservableCollection, ABC): """Home Assistant object.""" return self.store.hass - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> SerializedStorageCollection | None: """Load the data.""" - return cast(dict | None, await self.store.async_load()) + return await self.store.async_load() async def async_load(self) -> None: """Load the storage Manager.""" @@ -248,7 +262,7 @@ class StorageCollection(ObservableCollection, ABC): raw_storage = {"items": []} for item in raw_storage["items"]: - self.data[item[CONF_ID]] = item + self.data[item[CONF_ID]] = self._deserialize_item(item) await self.notify_changes( [ @@ -267,21 +281,35 @@ class StorageCollection(ObservableCollection, ABC): """Suggest an ID based on the config.""" @abstractmethod - async def _update_data(self, data: dict, update_data: dict) -> dict: - """Return a new updated data object.""" + async def _update_data(self, item: _T, update_data: dict) -> _T: + """Return a new updated item.""" - async def async_create_item(self, data: dict) -> dict: + @abstractmethod + def _create_item(self, item_id: str, data: dict) -> _T: + """Create an item from validated config.""" + + @abstractmethod + def _deserialize_item(self, data: dict) -> _T: + """Create an item from its serialized representation.""" + + @abstractmethod + def _serialize_item(self, item_id: str, item: _T) -> dict: + """Return the serialized representation of an item. + + The serialized representation must include the item_id in the "id" key. + """ + + async def async_create_item(self, data: dict) -> _T: """Create a new item.""" - item = await self._process_create_data(data) - item[CONF_ID] = self.id_manager.generate_id(self._get_suggested_id(item)) - self.data[item[CONF_ID]] = item + validated_data = await self._process_create_data(data) + item_id = self.id_manager.generate_id(self._get_suggested_id(validated_data)) + item = self._create_item(item_id, validated_data) + self.data[item_id] = item self._async_schedule_save() - await self.notify_changes( - [CollectionChangeSet(CHANGE_ADDED, item[CONF_ID], item)] - ) + await self.notify_changes([CollectionChangeSet(CHANGE_ADDED, item_id, item)]) return item - async def async_update_item(self, item_id: str, updates: dict) -> dict: + async def async_update_item(self, item_id: str, updates: dict) -> _T: """Update item.""" if item_id not in self.data: raise ItemNotFound(item_id) @@ -314,13 +342,34 @@ class StorageCollection(ObservableCollection, ABC): @callback def _async_schedule_save(self) -> None: - """Schedule saving the area registry.""" + """Schedule saving the collection.""" self.store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict: - """Return data of area registry to store in a file.""" - return {"items": list(self.data.values())} + def _data_to_save(self) -> SerializedStorageCollection: + """Return JSON-compatible date for storing to file.""" + return { + "items": [ + self._serialize_item(item_id, item) + for item_id, item in self.data.items() + ] + } + + +class DictStorageCollection(StorageCollection[dict]): + """A specialized StorageCollection where the items are untyped dicts.""" + + def _create_item(self, item_id: str, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return {CONF_ID: item_id} | data + + def _deserialize_item(self, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return data + + def _serialize_item(self, item_id: str, item: dict) -> dict: + """Return the serialized representation of an item.""" + return item class IDLessCollection(YamlCollection): @@ -410,9 +459,8 @@ def sync_entity_lifecycle( # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 824b1de701a..7045966c529 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -46,16 +46,15 @@ def async_listen( """ job = core.HassJob(callback, f"discovery listener {service}") - async def discovery_event_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" - task = hass.async_run_hass_job( - job, discovered["service"], discovered["discovered"] - ) - if task: - await task + hass.async_run_hass_job(job, discovered["service"], discovered["discovered"]) async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_event_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_event_listener, ) @@ -68,7 +67,10 @@ def discover( hass_config: ConfigType, ) -> None: """Fire discovery event. Can ensure a component is loaded.""" - hass.add_job(async_discover(hass, service, discovered, component, hass_config)) + hass.create_task( + async_discover(hass, service, discovered, component, hass_config), + f"discover {service} {component} {discovered}", + ) @bind_hass @@ -105,17 +107,17 @@ def async_listen_platform( service = EVENT_LOAD_PLATFORM.format(component) job = core.HassJob(callback, f"platform loaded {component}") - async def discovery_platform_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" if not (platform := discovered["platform"]): return - - task = hass.async_run_hass_job(job, platform, discovered.get("discovered")) - if task: - await task + hass.async_run_hass_job(job, platform, discovered.get("discovered")) return async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_platform_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_platform_listener, ) @@ -128,8 +130,9 @@ def load_platform( hass_config: ConfigType, ) -> None: """Load a component and platform dynamically.""" - hass.add_job( - async_load_platform(hass, component, platform, discovered, hass_config) + hass.create_task( + async_load_platform(hass, component, platform, discovered, hass_config), + f"discovery load_platform {component} {platform}", ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9d9e685d6a8..8352c1e4463 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -698,7 +698,10 @@ class Entity(ABC): If state is changed more than once before the ha state change task has been executed, the intermediate state transitions will be missed. """ - self.hass.add_job(self.async_update_ha_state(force_refresh)) + self.hass.create_task( + self.async_update_ha_state(force_refresh), + f"Entity {self.entity_id} schedule update ha state", + ) @callback def async_schedule_update_ha_state(self, force_refresh: bool = False) -> None: @@ -720,6 +723,15 @@ class Entity(ABC): else: self.async_write_ha_state() + @callback + def _async_slow_update_warning(self) -> None: + """Log a warning if update is taking too long.""" + _LOGGER.warning( + "Update of %s is taking over %s seconds", + self.entity_id, + SLOW_UPDATE_WARNING, + ) + async def async_device_update(self, warning: bool = True) -> None: """Process 'update' or 'async_update' from entity. @@ -727,42 +739,33 @@ class Entity(ABC): """ if self._update_staged: return + + hass = self.hass + assert hass is not None + + if hasattr(self, "async_update"): + coro: asyncio.Future[None] = self.async_update() + elif hasattr(self, "update"): + coro = hass.async_add_executor_job(self.update) + else: + return + self._update_staged = True # Process update sequential if self.parallel_updates: await self.parallel_updates.acquire() - try: - task: asyncio.Future[None] - if hasattr(self, "async_update"): - task = self.hass.async_create_task( - self.async_update(), f"Entity async update {self.entity_id}" - ) - elif hasattr(self, "update"): - task = self.hass.async_add_executor_job(self.update) - else: - return - - if not warning: - await task - return - - finished, _ = await asyncio.wait([task], timeout=SLOW_UPDATE_WARNING) - - for done in finished: - if exc := done.exception(): - raise exc - return - - _LOGGER.warning( - "Update of %s is taking over %s seconds", - self.entity_id, - SLOW_UPDATE_WARNING, + if warning: + update_warn = hass.loop.call_later( + SLOW_UPDATE_WARNING, self._async_slow_update_warning ) - await task + try: + await coro finally: self._update_staged = False + if warning: + update_warn.cancel() if self.parallel_updates: self.parallel_updates.release() @@ -945,25 +948,6 @@ class Entity(ABC): self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) - def __eq__(self, other: Any) -> bool: - """Return the comparison.""" - if not isinstance(other, self.__class__): - return False - - # Can only decide equality if both have a unique id - if self.unique_id is None or other.unique_id is None: - return False - - # Ensure they belong to the same platform - if self.platform is not None or other.platform is not None: - if self.platform is None or other.platform is None: - return False - - if self.platform.platform != other.platform.platform: - return False - - return self.unique_id == other.unique_id - def __repr__(self) -> str: """Return the representation.""" return f"" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 0c43dddec60..c79726684a2 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -114,7 +114,9 @@ class EntityComponent(Generic[_EntityT]): This doesn't block the executor to protect from deadlocks. """ - self.hass.add_job(self.async_setup(config)) + self.hass.create_task( + self.async_setup(config), f"EntityComponent setup {self.domain}" + ) async def async_setup(self, config: ConfigType) -> None: """Set up a full entity component. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7b7b809404f..0f93dca6939 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -479,7 +479,7 @@ class EntityPlatform: self.hass, self._update_entity_states, self.scan_interval, - f"EntityPlatform poll {self.domain}.{self.platform_name}", + name=f"EntityPlatform poll {self.domain}.{self.platform_name}", ) def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8b827bd24f..057e8f0955e 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -33,26 +33,20 @@ class EntityFilter: self._exclude_e = set(config[CONF_EXCLUDE_ENTITIES]) self._include_d = set(config[CONF_INCLUDE_DOMAINS]) self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) - self._include_eg = _convert_globs_to_pattern_list( - config[CONF_INCLUDE_ENTITY_GLOBS] - ) - self._exclude_eg = _convert_globs_to_pattern_list( - config[CONF_EXCLUDE_ENTITY_GLOBS] - ) + self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS]) + self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS]) self._filter: Callable[[str], bool] | None = None def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" return entity_id in self._include_e or ( - bool(self._include_eg) - and _test_against_patterns(self._include_eg, entity_id) + bool(self._include_eg and self._include_eg.match(entity_id)) ) def explicitly_excluded(self, entity_id: str) -> bool: """Check if an entity is explicitly excluded.""" return entity_id in self._exclude_e or ( - bool(self._exclude_eg) - and _test_against_patterns(self._exclude_eg, entity_id) + bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) def __call__(self, entity_id: str) -> bool: @@ -140,19 +134,22 @@ INCLUDE_EXCLUDE_FILTER_SCHEMA = vol.All( ) -def _glob_to_re(glob: str) -> re.Pattern[str]: - """Translate and compile glob string into pattern.""" - return re.compile(fnmatch.translate(glob)) - - -def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: - """Test entity against list of patterns, true if any match.""" - return any(pattern.match(entity_id) for pattern in patterns) - - -def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: +def _convert_globs_to_pattern(globs: list[str] | None) -> re.Pattern[str] | None: """Convert a list of globs to a re pattern list.""" - return list(map(_glob_to_re, set(globs or []))) + if globs is None: + return None + + translated_patterns: list[str] = [] + for glob in set(globs): + if pattern := fnmatch.translate(glob): + translated_patterns.append(pattern) + + if not translated_patterns: + return None + + inner = "|".join(translated_patterns) + combined = f"(?:{inner})" + return re.compile(combined) def generate_filter( @@ -169,8 +166,8 @@ def generate_filter( set(include_entities), set(exclude_domains), set(exclude_entities), - _convert_globs_to_pattern_list(include_entity_globs), - _convert_globs_to_pattern_list(exclude_entity_globs), + _convert_globs_to_pattern(include_entity_globs), + _convert_globs_to_pattern(exclude_entity_globs), ) @@ -179,8 +176,8 @@ def _generate_filter_from_sets_and_pattern_lists( include_e: set[str], exclude_d: set[str], exclude_e: set[str], - include_eg: list[re.Pattern[str]], - exclude_eg: list[re.Pattern[str]], + include_eg: re.Pattern[str] | None, + exclude_eg: re.Pattern[str] | None, ) -> Callable[[str], bool]: """Generate a filter from pre-comuted sets and pattern lists.""" have_exclude = bool(exclude_e or exclude_d or exclude_eg) @@ -191,7 +188,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in include_e or domain in include_d - or (bool(include_eg) and _test_against_patterns(include_eg, entity_id)) + or (bool(include_eg and include_eg.match(entity_id))) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -199,7 +196,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in exclude_e or domain in exclude_d - or (bool(exclude_eg) and _test_against_patterns(exclude_eg, entity_id)) + or (bool(exclude_eg and exclude_eg.match(entity_id))) ) # Case 1 - No filter @@ -249,12 +246,10 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_id in include_e or ( entity_id not in exclude_e and ( - (include_eg and _test_against_patterns(include_eg, entity_id)) + bool(include_eg and include_eg.match(entity_id)) or ( split_entity_id(entity_id)[0] in include_d - and not ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ) + and not (exclude_eg and exclude_eg.match(entity_id)) ) ) ) @@ -272,9 +267,7 @@ def _generate_filter_from_sets_and_pattern_lists( def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] - if domain in exclude_d or ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ): + if domain in exclude_d or bool(exclude_eg and exclude_eg.match(entity_id)): return entity_id in include_e return entity_id not in exclude_e diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index a456e12fa13..44a9cb087e3 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1397,6 +1397,7 @@ def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], interval: timedelta, + *, name: str | None = None, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index e35a66ada8d..d31c12d0fd5 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -219,7 +219,7 @@ class RestoreStateData: self.hass, _async_dump_states, STATE_DUMP_INTERVAL, - "RestoreStateData dump states", + name="RestoreStateData dump states", ) async def _async_dump_states_at_stop(*_: Any) -> None: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 481a59cee85..2c2e5b2d95e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable +from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -13,7 +13,7 @@ from functools import cache, lru_cache, partial, wraps import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -41,6 +41,7 @@ from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.const import ( @@ -49,6 +50,8 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, @@ -121,11 +124,77 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +# +# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities +# on a typical system. It is used as the initial size of the LRU cache +# for TemplateState objects. +# +# If the cache is too small we will end up creating and destroying +# TemplateState objects too often which will cause a lot of GC activity +# and slow down the system. For systems with a lot of entities and +# templates, this can reach 100000s of object creations and destructions +# per minute. +# +# Since entity counts may grow over time, we will increase +# the size if the number of entities grows via _async_adjust_lru_sizes +# at the start of the system and every 10 minutes if needed. +# CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 +CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( + CACHED_TEMPLATE_STATES +) +ENTITY_COUNT_GROWTH_FACTOR = 1.2 + + +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state without collecting.""" + if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): + return template_state + template_state = _create_template_state_no_collect(hass, state) + CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state + return template_state + + +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state that collects.""" + if template_state := CACHED_TEMPLATE_LRU.get(state): + return template_state + template_state = TemplateState(hass, state) + CACHED_TEMPLATE_LRU[state] = template_state + return template_state + + +def async_setup(hass: HomeAssistant) -> bool: + """Set up tracking the template LRUs.""" + + @callback + def _async_adjust_lru_sizes(_: Any) -> None: + """Adjust the lru cache sizes.""" + new_size = int( + round(hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR) + ) + for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): + # There is no typing for LRU + current_size = lru.get_size() # type: ignore[attr-defined] + if new_size > current_size: + lru.set_size(new_size) # type: ignore[attr-defined] + + from .event import ( # pylint: disable=import-outside-toplevel + async_track_time_interval, + ) + + cancel = async_track_time_interval( + hass, _async_adjust_lru_sizes, timedelta(minutes=10) + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_adjust_lru_sizes) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, callback(lambda _: cancel())) + return True + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -367,6 +436,7 @@ class Template: "_limited", "_strict", "_hash_cache", + "_renders", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -383,6 +453,7 @@ class Template: self._limited: bool | None = None self._strict: bool | None = None self._hash_cache: int = hash(self.template) + self._renders: int = 0 @property def _env(self) -> TemplateEnvironment: @@ -452,6 +523,8 @@ class Template: If limited is True, the template is not allowed to access any function or filter depending on hass or the state machine. """ + self._renders += 1 + if self.is_static: if not parse_result or self.hass and self.hass.config.legacy_templates: return self.template @@ -527,6 +600,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return False @@ -569,6 +644,7 @@ class Template: self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any ) -> RenderInfo: """Render the template and collect an entity filter.""" + self._renders += 1 assert self.hass and _RENDER_INFO not in self.hass.data render_info = RenderInfo(self) @@ -618,6 +694,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return self.template @@ -681,7 +759,7 @@ class Template: def __repr__(self) -> str: """Representation of Template.""" - return 'Template("' + self.template + '")' + return f"Template" @cache @@ -969,21 +1047,33 @@ class TemplateStateFromEntityId(TemplateStateBase): return f"