From 4e4ac795956b59bcbea91261c722c690b7e0f3ba Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Fri, 3 May 2024 07:06:40 -0400 Subject: [PATCH 001/164] Fix nws forecast coordinators and remove legacy forecast handling (#115857) Co-authored-by: J. Nick Koston --- homeassistant/components/nws/__init__.py | 108 +++++++------------- homeassistant/components/nws/manifest.json | 2 +- homeassistant/components/nws/sensor.py | 9 +- homeassistant/components/nws/weather.py | 112 ++++++--------------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nws/conftest.py | 1 + tests/components/nws/test_weather.py | 80 +-------------- 8 files changed, 82 insertions(+), 234 deletions(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 34157769b97..840d4d917f7 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -2,21 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from dataclasses import dataclass import datetime import logging -from typing import TYPE_CHECKING -from pynws import SimpleNWS +from pynws import SimpleNWS, call_with_retry from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.dt import utcnow @@ -27,8 +24,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=10) -FAILED_SCAN_INTERVAL = datetime.timedelta(minutes=1) -DEBOUNCE_TIME = 60 # in seconds +RETRY_INTERVAL = datetime.timedelta(minutes=1) +RETRY_STOP = datetime.timedelta(minutes=10) + +DEBOUNCE_TIME = 10 * 60 # in seconds def base_unique_id(latitude: float, longitude: float) -> str: @@ -41,62 +40,9 @@ class NWSData: """Data for the National Weather Service integration.""" api: SimpleNWS - coordinator_observation: NwsDataUpdateCoordinator - coordinator_forecast: NwsDataUpdateCoordinator - coordinator_forecast_hourly: NwsDataUpdateCoordinator - - -class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """NWS data update coordinator. - - Implements faster data update intervals for failed updates and exposes a last successful update time. - """ - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - *, - name: str, - update_interval: datetime.timedelta, - failed_update_interval: datetime.timedelta, - update_method: Callable[[], Awaitable[None]] | None = None, - request_refresh_debouncer: debounce.Debouncer | None = None, - ) -> None: - """Initialize NWS coordinator.""" - super().__init__( - hass, - logger, - name=name, - update_interval=update_interval, - update_method=update_method, - request_refresh_debouncer=request_refresh_debouncer, - ) - self.failed_update_interval = failed_update_interval - - @callback - def _schedule_refresh(self) -> None: - """Schedule a refresh.""" - if self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None - - # We _floor_ utcnow to create a schedule on a rounded second, - # minimizing the time between the point and the real activation. - # That way we obtain a constant update frequency, - # as long as the update process takes less than a second - if self.last_update_success: - if TYPE_CHECKING: - # the base class allows None, but this one doesn't - assert self.update_interval is not None - update_interval = self.update_interval - else: - update_interval = self.failed_update_interval - self._unsub_refresh = async_track_point_in_utc_time( - self.hass, - self._handle_refresh_interval, - utcnow().replace(microsecond=0) + update_interval, - ) + coordinator_observation: TimestampDataUpdateCoordinator[None] + coordinator_forecast: TimestampDataUpdateCoordinator[None] + coordinator_forecast_hourly: TimestampDataUpdateCoordinator[None] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -114,39 +60,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_observation() -> None: """Retrieve recent observations.""" - await nws_data.update_observation(start_time=utcnow() - UPDATE_TIME_PERIOD) + await call_with_retry( + nws_data.update_observation, + RETRY_INTERVAL, + RETRY_STOP, + start_time=utcnow() - UPDATE_TIME_PERIOD, + ) - coordinator_observation = NwsDataUpdateCoordinator( + async def update_forecast() -> None: + """Retrieve twice-daily forecsat.""" + await call_with_retry( + nws_data.update_forecast, + RETRY_INTERVAL, + RETRY_STOP, + ) + + async def update_forecast_hourly() -> None: + """Retrieve hourly forecast.""" + await call_with_retry( + nws_data.update_forecast_hourly, + RETRY_INTERVAL, + RETRY_STOP, + ) + + coordinator_observation = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", update_method=update_observation, update_interval=DEFAULT_SCAN_INTERVAL, - failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True ), ) - coordinator_forecast = NwsDataUpdateCoordinator( + coordinator_forecast = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS forecast station {station}", - update_method=nws_data.update_forecast, + update_method=update_forecast, update_interval=DEFAULT_SCAN_INTERVAL, - failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True ), ) - coordinator_forecast_hourly = NwsDataUpdateCoordinator( + coordinator_forecast_hourly = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS forecast hourly station {station}", - update_method=nws_data.update_forecast_hourly, + update_method=update_forecast_hourly, update_interval=DEFAULT_SCAN_INTERVAL, - failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True ), diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 4006a145db4..f68d76ee95b 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.6.0"] + "requirements": ["pynws[retry]==1.7.0"] } diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 1d8c5ab045e..447c2dc5cf8 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -25,7 +25,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + TimestampDataUpdateCoordinator, +) from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -34,7 +37,7 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import NWSData, NwsDataUpdateCoordinator, base_unique_id, device_info +from . import NWSData, base_unique_id, device_info from .const import ATTRIBUTION, CONF_STATION, DOMAIN, OBSERVATION_VALID_TIME PARALLEL_UPDATES = 0 @@ -158,7 +161,7 @@ async def async_setup_entry( ) -class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): +class NWSSensor(CoordinatorEntity[TimestampDataUpdateCoordinator[None]], SensorEntity): """An NWS Sensor Entity.""" entity_description: NWSSensorEntityDescription diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 89414f5acf1..c017d579c3a 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import partial from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast @@ -34,7 +35,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter from . import NWSData, base_unique_id, device_info @@ -46,7 +46,6 @@ from .const import ( DOMAIN, FORECAST_VALID_TIME, HOURLY, - OBSERVATION_VALID_TIME, ) PARALLEL_UPDATES = 0 @@ -140,96 +139,69 @@ class NWSWeather(CoordinatorWeatherEntity): self.nws = nws_data.api latitude = entry_data[CONF_LATITUDE] longitude = entry_data[CONF_LONGITUDE] - self.coordinator_forecast_legacy = nws_data.coordinator_forecast - self.station = self.nws.station - self.observation: dict[str, Any] | None = None - self._forecast_hourly: list[dict[str, Any]] | None = None - self._forecast_legacy: list[dict[str, Any]] | None = None - self._forecast_twice_daily: list[dict[str, Any]] | None = None + self.station = self.nws.station self._attr_unique_id = _calculate_unique_id(entry_data, DAYNIGHT) self._attr_device_info = device_info(latitude, longitude) self._attr_name = self.station async def async_added_to_hass(self) -> None: - """Set up a listener and load data.""" + """When entity is added to hass.""" await super().async_added_to_hass() - self.async_on_remove( - self.coordinator_forecast_legacy.async_add_listener( - self._handle_legacy_forecast_coordinator_update + self.async_on_remove(partial(self._remove_forecast_listener, "daily")) + self.async_on_remove(partial(self._remove_forecast_listener, "hourly")) + self.async_on_remove(partial(self._remove_forecast_listener, "twice_daily")) + + for forecast_type in ("twice_daily", "hourly"): + if (coordinator := self.forecast_coordinators[forecast_type]) is None: + continue + self.unsub_forecast[forecast_type] = coordinator.async_add_listener( + partial(self._handle_forecast_update, forecast_type) ) - ) - # Load initial data from coordinators - self._handle_coordinator_update() - self._handle_hourly_forecast_coordinator_update() - self._handle_twice_daily_forecast_coordinator_update() - self._handle_legacy_forecast_coordinator_update() - - @callback - def _handle_coordinator_update(self) -> None: - """Load data from integration.""" - self.observation = self.nws.observation - self.async_write_ha_state() - - @callback - def _handle_hourly_forecast_coordinator_update(self) -> None: - """Handle updated data from the hourly forecast coordinator.""" - self._forecast_hourly = self.nws.forecast_hourly - - @callback - def _handle_twice_daily_forecast_coordinator_update(self) -> None: - """Handle updated data from the twice daily forecast coordinator.""" - self._forecast_twice_daily = self.nws.forecast - - @callback - def _handle_legacy_forecast_coordinator_update(self) -> None: - """Handle updated data from the legacy forecast coordinator.""" - self._forecast_legacy = self.nws.forecast - self.async_write_ha_state() @property def native_temperature(self) -> float | None: """Return the current temperature.""" - if self.observation: - return self.observation.get("temperature") + if observation := self.nws.observation: + return observation.get("temperature") return None @property def native_pressure(self) -> int | None: """Return the current pressure.""" - if self.observation: - return self.observation.get("seaLevelPressure") + if observation := self.nws.observation: + return observation.get("seaLevelPressure") return None @property def humidity(self) -> float | None: """Return the name of the sensor.""" - if self.observation: - return self.observation.get("relativeHumidity") + if observation := self.nws.observation: + return observation.get("relativeHumidity") return None @property def native_wind_speed(self) -> float | None: """Return the current windspeed.""" - if self.observation: - return self.observation.get("windSpeed") + if observation := self.nws.observation: + return observation.get("windSpeed") return None @property def wind_bearing(self) -> int | None: """Return the current wind bearing (degrees).""" - if self.observation: - return self.observation.get("windDirection") + if observation := self.nws.observation: + return observation.get("windDirection") return None @property def condition(self) -> str | None: """Return current condition.""" weather = None - if self.observation: - weather = self.observation.get("iconWeather") - time = cast(str, self.observation.get("iconTime")) + if observation := self.nws.observation: + weather = observation.get("iconWeather") + time = cast(str, observation.get("iconTime")) if weather: return convert_condition(time, weather) @@ -238,8 +210,8 @@ class NWSWeather(CoordinatorWeatherEntity): @property def native_visibility(self) -> int | None: """Return visibility.""" - if self.observation: - return self.observation.get("visibility") + if observation := self.nws.observation: + return observation.get("visibility") return None def _forecast( @@ -302,33 +274,12 @@ class NWSWeather(CoordinatorWeatherEntity): @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" - return self._forecast(self._forecast_hourly, HOURLY) + return self._forecast(self.nws.forecast_hourly, HOURLY) @callback def _async_forecast_twice_daily(self) -> list[Forecast] | None: """Return the twice daily forecast in native units.""" - return self._forecast(self._forecast_twice_daily, DAYNIGHT) - - @property - def available(self) -> bool: - """Return if state is available.""" - last_success = ( - self.coordinator.last_update_success - and self.coordinator_forecast_legacy.last_update_success - ) - if ( - self.coordinator.last_update_success_time - and self.coordinator_forecast_legacy.last_update_success_time - ): - last_success_time = ( - utcnow() - self.coordinator.last_update_success_time - < OBSERVATION_VALID_TIME - and utcnow() - self.coordinator_forecast_legacy.last_update_success_time - < FORECAST_VALID_TIME - ) - else: - last_success_time = False - return last_success or last_success_time + return self._forecast(self.nws.forecast, DAYNIGHT) async def async_update(self) -> None: """Update the entity. @@ -336,4 +287,7 @@ class NWSWeather(CoordinatorWeatherEntity): Only used by the generic entity update service. """ await self.coordinator.async_request_refresh() - await self.coordinator_forecast_legacy.async_request_refresh() + + for forecast_type in ("twice_daily", "hourly"): + if (coordinator := self.forecast_coordinators[forecast_type]) is not None: + await coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index f391511e607..c31b3d92b5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2001,7 +2001,7 @@ pynobo==1.8.1 pynuki==1.6.3 # homeassistant.components.nws -pynws==1.6.0 +pynws[retry]==1.7.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 140741518d1..8f1786020fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1564,7 +1564,7 @@ pynobo==1.8.1 pynuki==1.6.3 # homeassistant.components.nws -pynws==1.6.0 +pynws[retry]==1.7.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/tests/components/nws/conftest.py b/tests/components/nws/conftest.py index ac2c281c57b..48401fe87ba 100644 --- a/tests/components/nws/conftest.py +++ b/tests/components/nws/conftest.py @@ -11,6 +11,7 @@ from .const import DEFAULT_FORECAST, DEFAULT_OBSERVATION @pytest.fixture def mock_simple_nws(): """Mock pynws SimpleNWS with default values.""" + with patch("homeassistant.components.nws.SimpleNWS") as mock_nws: instance = mock_nws.return_value instance.set_station = AsyncMock(return_value=None) diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index ad40b576a8a..87aae18be60 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -13,7 +13,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN @@ -181,7 +180,7 @@ async def test_entity_refresh(hass: HomeAssistant, mock_simple_nws, no_sensor) - await hass.async_block_till_done() assert instance.update_observation.call_count == 2 assert instance.update_forecast.call_count == 2 - instance.update_forecast_hourly.assert_called_once() + assert instance.update_forecast_hourly.call_count == 2 async def test_error_observation( @@ -189,18 +188,8 @@ async def test_error_observation( ) -> None: """Test error during update observation.""" utc_time = dt_util.utcnow() - with ( - patch("homeassistant.components.nws.utcnow") as mock_utc, - patch("homeassistant.components.nws.weather.utcnow") as mock_utc_weather, - ): - - def increment_time(time): - mock_utc.return_value += time - mock_utc_weather.return_value += time - async_fire_time_changed(hass, mock_utc.return_value) - + with patch("homeassistant.components.nws.utcnow") as mock_utc: mock_utc.return_value = utc_time - mock_utc_weather.return_value = utc_time instance = mock_simple_nws.return_value # first update fails instance.update_observation.side_effect = aiohttp.ClientError @@ -219,68 +208,6 @@ async def test_error_observation( assert state assert state.state == STATE_UNAVAILABLE - # second update happens faster and succeeds - instance.update_observation.side_effect = None - increment_time(timedelta(minutes=1)) - await hass.async_block_till_done() - - assert instance.update_observation.call_count == 2 - - state = hass.states.get("weather.abc") - assert state - assert state.state == ATTR_CONDITION_SUNNY - - # third udate fails, but data is cached - instance.update_observation.side_effect = aiohttp.ClientError - - increment_time(timedelta(minutes=10)) - await hass.async_block_till_done() - - assert instance.update_observation.call_count == 3 - - state = hass.states.get("weather.abc") - assert state - assert state.state == ATTR_CONDITION_SUNNY - - # after 20 minutes data caching expires, data is no longer shown - increment_time(timedelta(minutes=10)) - await hass.async_block_till_done() - - state = hass.states.get("weather.abc") - assert state - assert state.state == STATE_UNAVAILABLE - - -async def test_error_forecast(hass: HomeAssistant, mock_simple_nws, no_sensor) -> None: - """Test error during update forecast.""" - instance = mock_simple_nws.return_value - instance.update_forecast.side_effect = aiohttp.ClientError - - entry = MockConfigEntry( - domain=nws.DOMAIN, - data=NWS_CONFIG, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - instance.update_forecast.assert_called_once() - - state = hass.states.get("weather.abc") - assert state - assert state.state == STATE_UNAVAILABLE - - instance.update_forecast.side_effect = None - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) - await hass.async_block_till_done() - - assert instance.update_forecast.call_count == 2 - - state = hass.states.get("weather.abc") - assert state - assert state.state == ATTR_CONDITION_SUNNY - async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: """Test the expected entities are created.""" @@ -304,7 +231,6 @@ async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: ("service"), [ SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, ], ) async def test_forecast_service( @@ -355,7 +281,7 @@ async def test_forecast_service( assert instance.update_observation.call_count == 2 assert instance.update_forecast.call_count == 2 - assert instance.update_forecast_hourly.call_count == 1 + assert instance.update_forecast_hourly.call_count == 2 for forecast_type in ("twice_daily", "hourly"): response = await hass.services.async_call( From 624e4a2b483f55d6e4c8a50fe9193506b03ba8a0 Mon Sep 17 00:00:00 2001 From: GraceGRD <123941606+GraceGRD@users.noreply.github.com> Date: Wed, 1 May 2024 23:13:09 +0200 Subject: [PATCH 002/164] Bump opentherm_gw to 2.2.0 (#116527) --- homeassistant/components/opentherm_gw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index 50e0eab2643..b6ebef6e83c 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/opentherm_gw", "iot_class": "local_push", "loggers": ["pyotgw"], - "requirements": ["pyotgw==2.1.3"] + "requirements": ["pyotgw==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c31b3d92b5e..61789f0369a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2031,7 +2031,7 @@ pyoppleio-legacy==1.0.8 pyosoenergyapi==1.1.3 # homeassistant.components.opentherm_gw -pyotgw==2.1.3 +pyotgw==2.2.0 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f1786020fb..8366bc7fb6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1588,7 +1588,7 @@ pyopnsense==0.4.0 pyosoenergyapi==1.1.3 # homeassistant.components.opentherm_gw -pyotgw==2.1.3 +pyotgw==2.2.0 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp From 49de59432efad76608571d44bec57eb551255fc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 19:23:43 -0500 Subject: [PATCH 003/164] Add a lock to homekit_controller platform loads (#116539) --- .../homekit_controller/connection.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 78beb7bfffa..78190634aff 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -153,6 +153,7 @@ class HKDevice: self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {} self._pending_subscribes: set[tuple[int, int]] = set() self._subscribe_timer: CALLBACK_TYPE | None = None + self._load_platforms_lock = asyncio.Lock() @property def entity_map(self) -> Accessories: @@ -327,7 +328,8 @@ class HKDevice: ) # BLE devices always get an RSSI sensor as well if "sensor" not in self.platforms: - await self._async_load_platforms({"sensor"}) + async with self._load_platforms_lock: + await self._async_load_platforms({"sensor"}) @callback def _async_start_polling(self) -> None: @@ -804,6 +806,7 @@ class HKDevice: async def _async_load_platforms(self, platforms: set[str]) -> None: """Load a group of platforms.""" + assert self._load_platforms_lock.locked(), "Must be called with lock held" if not (to_load := platforms - self.platforms): return self.platforms.update(to_load) @@ -813,22 +816,23 @@ class HKDevice: async def async_load_platforms(self) -> None: """Load any platforms needed by this HomeKit device.""" - to_load: set[str] = set() - for accessory in self.entity_map.accessories: - for service in accessory.services: - if service.type in HOMEKIT_ACCESSORY_DISPATCH: - platform = HOMEKIT_ACCESSORY_DISPATCH[service.type] - if platform not in self.platforms: - to_load.add(platform) - - for char in service.characteristics: - if char.type in CHARACTERISTIC_PLATFORMS: - platform = CHARACTERISTIC_PLATFORMS[char.type] + async with self._load_platforms_lock: + to_load: set[str] = set() + for accessory in self.entity_map.accessories: + for service in accessory.services: + if service.type in HOMEKIT_ACCESSORY_DISPATCH: + platform = HOMEKIT_ACCESSORY_DISPATCH[service.type] if platform not in self.platforms: to_load.add(platform) - if to_load: - await self._async_load_platforms(to_load) + for char in service.characteristics: + if char.type in CHARACTERISTIC_PLATFORMS: + platform = CHARACTERISTIC_PLATFORMS[char.type] + if platform not in self.platforms: + to_load.add(platform) + + if to_load: + await self._async_load_platforms(to_load) @callback def async_update_available_state(self, *_: Any) -> None: From ea6a9b83162eb8448056ffc1522ae67ad7cf3f2f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 May 2024 21:19:55 +0200 Subject: [PATCH 004/164] Fix MQTT discovery cooldown too short with large setup (#116550) * Fix MQTT discovery cooldown too short with large setup * Set to 5 sec * Only change the discovery cooldown * Fire immediatly when teh debouncing period is over --- homeassistant/components/mqtt/client.py | 16 ++++++++++++---- homeassistant/components/mqtt/discovery.py | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index d79492ccb27..4fa9f4a1d49 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -83,7 +83,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -DISCOVERY_COOLDOWN = 2 +DISCOVERY_COOLDOWN = 5 INITIAL_SUBSCRIBE_COOLDOWN = 1.0 SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 @@ -349,6 +349,12 @@ class EnsureJobAfterCooldown: self._task = create_eager_task(self._async_job()) self._task.add_done_callback(self._async_task_done) + async def async_fire(self) -> None: + """Execute the job immediately.""" + if self._task: + await self._task + self._async_execute() + @callback def _async_cancel_timer(self) -> None: """Cancel any pending task.""" @@ -846,7 +852,7 @@ class MQTT: for topic, qos in subscriptions.items(): _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) - self._last_subscribe = time.time() + self._last_subscribe = time.monotonic() if result == 0: await self._wait_for_mid(mid) @@ -876,6 +882,8 @@ class MQTT: await self._ha_started.wait() # Wait for Home Assistant to start await self._discovery_cooldown() # Wait for MQTT discovery to cool down # Update subscribe cooldown period to a shorter time + # and make sure we flush the debouncer + await self._subscribe_debouncer.async_fire() self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) await self.async_publish( topic=birth_message.topic, @@ -1121,7 +1129,7 @@ class MQTT: async def _discovery_cooldown(self) -> None: """Wait until all discovery and subscriptions are processed.""" - now = time.time() + now = time.monotonic() # Reset discovery and subscribe cooldowns self._mqtt_data.last_discovery = now self._last_subscribe = now @@ -1133,7 +1141,7 @@ class MQTT: ) while now < wait_until: await asyncio.sleep(wait_until - now) - now = time.time() + now = time.monotonic() last_discovery = self._mqtt_data.last_discovery last_subscribe = ( now if self._pending_subscriptions else self._last_subscribe diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index e330cd9b44b..08d86c1a1a4 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -177,7 +177,7 @@ async def async_start( # noqa: C901 @callback def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 """Process the received message.""" - mqtt_data.last_discovery = time.time() + mqtt_data.last_discovery = time.monotonic() payload = msg.payload topic = msg.topic topic_trimmed = topic.replace(f"{discovery_topic}/", "", 1) @@ -370,7 +370,7 @@ async def async_start( # noqa: C901 ) ) - mqtt_data.last_discovery = time.time() + mqtt_data.last_discovery = time.monotonic() mqtt_integrations = await async_get_mqtt(hass) for integration, topics in mqtt_integrations.items(): From 5da6f83d10afbce4e1ff899223351bd05c46c289 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 1 May 2024 16:32:56 -0400 Subject: [PATCH 005/164] Bump upb_lib to 0.5.6 (#116558) --- homeassistant/components/upb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 240660ac89f..a5e32dd298e 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.5.4"] + "requirements": ["upb-lib==0.5.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 61789f0369a..452de53e4e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.18 # homeassistant.components.upb -upb-lib==0.5.4 +upb-lib==0.5.6 # homeassistant.components.upcloud upcloud-api==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8366bc7fb6d..4f21b948bda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2147,7 +2147,7 @@ unifi-discovery==1.1.8 universal-silabs-flasher==0.0.18 # homeassistant.components.upb -upb-lib==0.5.4 +upb-lib==0.5.6 # homeassistant.components.upcloud upcloud-api==2.0.0 From 65839067e33bc95fdc2d577b2fba2f0b01bdada5 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 1 May 2024 20:51:04 -0400 Subject: [PATCH 006/164] Bump elkm1_lib to 2.2.7 (#116564) Co-authored-by: J. Nick Koston --- homeassistant/components/elkm1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 3ec5be46d41..5edab8463f7 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.6"] + "requirements": ["elkm1-lib==2.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 452de53e4e4..b72209ee772 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,7 +777,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.6 +elkm1-lib==2.2.7 # homeassistant.components.elmax elmax-api==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f21b948bda..e49c42a4594 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ electrickiwi-api==0.8.5 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.6 +elkm1-lib==2.2.7 # homeassistant.components.elmax elmax-api==0.0.4 From 523de94184056760b1c69d422354a4b6c04e56a1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 3 May 2024 13:27:01 +0200 Subject: [PATCH 007/164] Fix Matter startup when Matter bridge is present (#116569) --- homeassistant/components/matter/light.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 9d80ebc38f6..da72798dda1 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -398,6 +398,8 @@ class MatterLight(MatterEntity, LightEntity): def _check_transition_blocklist(self) -> None: """Check if this device is reported to have non working transitions.""" device_info = self._endpoint.device_info + if isinstance(device_info, clusters.BridgedDeviceBasicInformation): + return if ( device_info.vendorID, device_info.productID, From 99ab8d29561e51d30182e66933ba33522ad9d1ec Mon Sep 17 00:00:00 2001 From: Tomasz Date: Thu, 2 May 2024 00:21:40 +0200 Subject: [PATCH 008/164] Bump sanix to 1.0.6 (#116570) dependency version bump --- homeassistant/components/sanix/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sanix/manifest.json b/homeassistant/components/sanix/manifest.json index 4e1c6d56add..facf8f7a4dd 100644 --- a/homeassistant/components/sanix/manifest.json +++ b/homeassistant/components/sanix/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sanix", "iot_class": "cloud_polling", - "requirements": ["sanix==1.0.5"] + "requirements": ["sanix==1.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index b72209ee772..7c90437ba27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2499,7 +2499,7 @@ samsungctl[websocket]==0.7.1 samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.sanix -sanix==1.0.5 +sanix==1.0.6 # homeassistant.components.satel_integra satel-integra==0.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e49c42a4594..05106fefc6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1939,7 +1939,7 @@ samsungctl[websocket]==0.7.1 samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.sanix -sanix==1.0.5 +sanix==1.0.6 # homeassistant.components.screenlogic screenlogicpy==0.10.0 From fabbe2f28fd18cfb35fc3038481b8eacbec2acda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 2 May 2024 02:19:40 +0200 Subject: [PATCH 009/164] Fix Airthings BLE model names (#116579) --- homeassistant/components/airthings_ble/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 8031b802eae..3b012ed7316 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -225,7 +225,7 @@ class AirthingsSensor( manufacturer=airthings_device.manufacturer, hw_version=airthings_device.hw_version, sw_version=airthings_device.sw_version, - model=airthings_device.model.name, + model=airthings_device.model.product_name, ) @property From 0e488ef50512a1d8e6b02182d9017ecc9d25561a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 2 May 2024 15:57:47 +0200 Subject: [PATCH 010/164] Improve coordinator in Ondilo ico (#116596) * Improve coordinator in Ondilo ico * Improve coordinator in Ondilo ico --- .coveragerc | 1 + .../components/ondilo_ico/__init__.py | 10 ++- .../components/ondilo_ico/coordinator.py | 37 ++++++++++ homeassistant/components/ondilo_ico/sensor.py | 68 +++---------------- 4 files changed, 57 insertions(+), 59 deletions(-) create mode 100644 homeassistant/components/ondilo_ico/coordinator.py diff --git a/.coveragerc b/.coveragerc index 1ccb9e461df..10dedd43e81 100644 --- a/.coveragerc +++ b/.coveragerc @@ -939,6 +939,7 @@ omit = homeassistant/components/omnilogic/switch.py homeassistant/components/ondilo_ico/__init__.py homeassistant/components/ondilo_ico/api.py + homeassistant/components/ondilo_ico/coordinator.py homeassistant/components/ondilo_ico/sensor.py homeassistant/components/onkyo/media_player.py homeassistant/components/onvif/__init__.py diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index 5dccca54772..aa541c470f1 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api, config_flow from .const import DOMAIN +from .coordinator import OndiloIcoCoordinator from .oauth_impl import OndiloOauth2Implementation PLATFORMS = [Platform.SENSOR] @@ -26,8 +27,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = api.OndiloClient(hass, entry, implementation) + coordinator = OndiloIcoCoordinator( + hass, api.OndiloClient(hass, entry, implementation) + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py new file mode 100644 index 00000000000..d3e9b4a4e11 --- /dev/null +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -0,0 +1,37 @@ +"""Define an object to coordinate fetching Ondilo ICO data.""" + +from datetime import timedelta +import logging +from typing import Any + +from ondilo import OndiloError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import DOMAIN +from .api import OndiloClient + +_LOGGER = logging.getLogger(__name__) + + +class OndiloIcoCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): + """Class to manage fetching Ondilo ICO data from API.""" + + def __init__(self, hass: HomeAssistant, api: OndiloClient) -> None: + """Initialize.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + self.api = api + + async def _async_update_data(self) -> list[dict[str, Any]]: + """Fetch data from API endpoint.""" + try: + return await self.hass.async_add_executor_job(self.api.get_all_pools_data) + + except OndiloError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 17569fd784f..5f21fb6a909 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -2,12 +2,6 @@ from __future__ import annotations -from datetime import timedelta -import logging -from typing import Any - -from ondilo import OndiloError - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -24,14 +18,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import OndiloClient from .const import DOMAIN +from .coordinator import OndiloIcoCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -78,66 +68,30 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ) -SCAN_INTERVAL = timedelta(minutes=5) -_LOGGER = logging.getLogger(__name__) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Ondilo ICO sensors.""" - api: OndiloClient = hass.data[DOMAIN][entry.entry_id] + coordinator: OndiloIcoCoordinator = hass.data[DOMAIN][entry.entry_id] - async def async_update_data() -> list[dict[str, Any]]: - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ - try: - return await hass.async_add_executor_job(api.get_all_pools_data) - - except OndiloError as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name="sensor", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=SCAN_INTERVAL, + async_add_entities( + OndiloICO(coordinator, poolidx, description) + for poolidx, pool in enumerate(coordinator.data) + for sensor in pool["sensors"] + for description in SENSOR_TYPES + if description.key == sensor["data_type"] ) - # Fetch initial data so we have data when entities subscribe - await coordinator.async_refresh() - entities = [] - for poolidx, pool in enumerate(coordinator.data): - entities.extend( - [ - OndiloICO(coordinator, poolidx, description) - for sensor in pool["sensors"] - for description in SENSOR_TYPES - if description.key == sensor["data_type"] - ] - ) - - async_add_entities(entities) - - -class OndiloICO( - CoordinatorEntity[DataUpdateCoordinator[list[dict[str, Any]]]], SensorEntity -): +class OndiloICO(CoordinatorEntity[OndiloIcoCoordinator], SensorEntity): """Representation of a Sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[list[dict[str, Any]]], + coordinator: OndiloIcoCoordinator, poolidx: int, description: SensorEntityDescription, ) -> None: From 575a3da772d7c4bec39dbdb519554372c00890cf Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 2 May 2024 11:44:32 +0200 Subject: [PATCH 011/164] Fix inheritance order for KNX notify (#116600) --- homeassistant/components/knx/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index e208e4fd646..f206ee62ece 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -97,7 +97,7 @@ def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotific ) -class KNXNotify(NotifyEntity, KnxEntity): +class KNXNotify(KnxEntity, NotifyEntity): """Representation of a KNX notification entity.""" _device: XknxNotification From 7c1502fa059610290d4cb0e7391bd36a5bafcc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 2 May 2024 16:37:02 +0200 Subject: [PATCH 012/164] Bump Airthings BLE to 0.8.0 (#116616) Co-authored-by: J. Nick Koston --- homeassistant/components/airthings_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 3f7bd02a33e..d93e3a0b8cb 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.7.1"] + "requirements": ["airthings-ble==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c90437ba27..620a1aa2a15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -413,7 +413,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.7.1 +airthings-ble==0.8.0 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05106fefc6d..8adb04408e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,7 +386,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.7.1 +airthings-ble==0.8.0 # homeassistant.components.airthings airthings-cloud==0.2.0 From 8193b82f4a37613e9ccfce62b616c7e27eee471e Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Thu, 2 May 2024 16:54:06 +0200 Subject: [PATCH 013/164] Bump pywaze to 1.0.1 (#116621) Co-authored-by: J. Nick Koston --- homeassistant/components/waze_travel_time/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 4fc08cf983d..ce7c9105781 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", "iot_class": "cloud_polling", "loggers": ["pywaze", "homeassistant.helpers.location"], - "requirements": ["pywaze==1.0.0"] + "requirements": ["pywaze==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 620a1aa2a15..c5710500ad5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2370,7 +2370,7 @@ pyvlx==0.2.21 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==1.0.0 +pywaze==1.0.1 # homeassistant.components.weatherflow pyweatherflowudp==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8adb04408e1..84f034d453f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1843,7 +1843,7 @@ pyvlx==0.2.21 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==1.0.0 +pywaze==1.0.1 # homeassistant.components.weatherflow pyweatherflowudp==1.4.5 From c338f1b964e8f5727f284b9d51fddeaa7e42a3cc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 2 May 2024 16:12:26 +0200 Subject: [PATCH 014/164] Add constraint for tuf (#116627) --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b1c0391022a..2c038ed3927 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -192,3 +192,8 @@ pycountry>=23.12.11 # scapy<2.5.0 will not work with python3.12 scapy>=2.5.0 + +# tuf isn't updated to deal with breaking changes in securesystemslib==1.0. +# Only tuf>=4 includes a constraint to <1.0. +# https://github.com/theupdateframework/python-tuf/releases/tag/v4.0.0 +tuf>=4.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a5db9997d9d..b611b050c7d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -214,6 +214,11 @@ pycountry>=23.12.11 # scapy<2.5.0 will not work with python3.12 scapy>=2.5.0 + +# tuf isn't updated to deal with breaking changes in securesystemslib==1.0. +# Only tuf>=4 includes a constraint to <1.0. +# https://github.com/theupdateframework/python-tuf/releases/tag/v4.0.0 +tuf>=4.0.0 """ GENERATED_MESSAGE = ( From 6be25c784d5141acd67256708e9149ff724f162c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 2 May 2024 19:53:17 +0200 Subject: [PATCH 015/164] Bump aiounifi to v77 (#116639) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 982d654c8fe..504c2f505a7 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==76"], + "requirements": ["aiounifi==77"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index c5710500ad5..c54606830dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ aiotankerkoenig==0.4.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==76 +aiounifi==77 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84f034d453f..23730ab802e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -359,7 +359,7 @@ aiotankerkoenig==0.4.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==76 +aiounifi==77 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From c36fd5550b8cecf9e405c3de22253cebd9f8b37f Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Fri, 3 May 2024 13:07:45 +0200 Subject: [PATCH 016/164] Bump govee-light-local library and fix wrong information for Govee lights (#116651) --- homeassistant/components/govee_light_local/light.py | 4 ++-- homeassistant/components/govee_light_local/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 836f48d2ea9..60bf07e8e19 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -94,7 +94,7 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): name=device.sku, manufacturer=MANUFACTURER, model=device.sku, - connections={(CONNECTION_NETWORK_MAC, device.fingerprint)}, + serial_number=device.fingerprint, ) @property diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index cb7955f5407..df72a082190 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==1.4.4"] + "requirements": ["govee-local-api==1.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index c54606830dd..362b8389df2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -983,7 +983,7 @@ gotailwind==0.2.2 govee-ble==0.31.2 # homeassistant.components.govee_light_local -govee-local-api==1.4.4 +govee-local-api==1.4.5 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23730ab802e..5672dd88dab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -806,7 +806,7 @@ gotailwind==0.2.2 govee-ble==0.31.2 # homeassistant.components.govee_light_local -govee-local-api==1.4.4 +govee-local-api==1.4.5 # homeassistant.components.gpsd gps3==0.33.3 From abeb65e43d54136534c8c17d6eec14a07f89ee27 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 2 May 2024 20:31:28 -0400 Subject: [PATCH 017/164] Bump ZHA dependency bellows to 0.38.4 (#116660) Bump ZHA dependencies Co-authored-by: TheJulianJES --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index b1511b2f5bb..7a407a2eb33 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.38.3", + "bellows==0.38.4", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.115", diff --git a/requirements_all.txt b/requirements_all.txt index 362b8389df2..8f15e0be921 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -541,7 +541,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.38.3 +bellows==0.38.4 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5672dd88dab..a20a6a92811 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.zha -bellows==0.38.3 +bellows==0.38.4 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.2 From ac302f38b1926c404dc7c68039c27faee8f57422 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 May 2024 18:15:56 -0500 Subject: [PATCH 018/164] Bump habluetooth to 2.8.1 (#116661) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4bb84ab6dc3..754e8faf996 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.8.0" + "habluetooth==2.8.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2c038ed3927..800e4d90009 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==2.8.0 +habluetooth==2.8.1 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8f15e0be921..419c713347b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1035,7 +1035,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.8.0 +habluetooth==2.8.1 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a20a6a92811..31832687250 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -849,7 +849,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.8.0 +habluetooth==2.8.1 # homeassistant.components.cloud hass-nabucasa==0.78.0 From 66bb3ecac905ae8ab0bd5265d272e9d714ae2b94 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Thu, 2 May 2024 19:17:41 -0400 Subject: [PATCH 019/164] Bump env_canada lib to 0.6.2 (#116662) --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index d0c34b0cf9a..f29c8177dfd 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.6.0"] + "requirements": ["env-canada==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 419c713347b..6f741478f20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -804,7 +804,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.6.0 +env-canada==0.6.2 # homeassistant.components.season ephem==4.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31832687250..14951b8a6ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -658,7 +658,7 @@ energyzero==2.1.0 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.6.0 +env-canada==0.6.2 # homeassistant.components.season ephem==4.1.5 From 7a56ba1506fd4fec10d7ee6f02a9e9860896076b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 May 2024 05:17:01 -0500 Subject: [PATCH 020/164] Block dreame_vacuum versions older than 1.0.4 (#116673) --- homeassistant/loader.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 1a72c8eb351..89c3442be6a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -90,7 +90,12 @@ class BlockedIntegration: BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { # Added in 2024.3.0 because of https://github.com/home-assistant/core/issues/112464 - "start_time": BlockedIntegration(AwesomeVersion("1.1.7"), "breaks Home Assistant") + "start_time": BlockedIntegration(AwesomeVersion("1.1.7"), "breaks Home Assistant"), + # Added in 2024.5.1 because of + # https://community.home-assistant.io/t/psa-2024-5-upgrade-failure-and-dreame-vacuum-custom-integration/724612 + "dreame_vacuum": BlockedIntegration( + AwesomeVersion("1.0.4"), "crashes Home Assistant" + ), } DATA_COMPONENTS = "components" From a4f9a645889b628c82872102dc4706071d97dfe9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 May 2024 13:07:12 +0200 Subject: [PATCH 021/164] Fix fyta test timezone handling (#116689) --- tests/components/fyta/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index 69478d04ca0..dedb468a617 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry USERNAME = "fyta_user" PASSWORD = "fyta_pass" ACCESS_TOKEN = "123xyz" -EXPIRATION = datetime.fromisoformat("2024-12-31T10:00:00").astimezone(UTC) +EXPIRATION = datetime.fromisoformat("2024-12-31T10:00:00").replace(tzinfo=UTC) async def test_user_flow( From 7e8cbafc6f4ac511c8d99f5264c8def76f4983db Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Fri, 3 May 2024 08:11:22 -0300 Subject: [PATCH 022/164] Fix BroadlinkRemote._learn_command() (#116692) --- homeassistant/components/broadlink/remote.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 55368e5ff59..77c9ea0ff98 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -373,8 +373,11 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): start_time = dt_util.utcnow() while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) - found = await device.async_request(device.api.check_frequency)[0] - if found: + is_found, frequency = await device.async_request( + device.api.check_frequency + ) + if is_found: + _LOGGER.info("Radiofrequency detected: %s MHz", frequency) break else: await device.async_request(device.api.cancel_sweep_frequency) From 9d2fd8217f1ed7e03afb2aca7014d7625c03cea3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 May 2024 13:38:38 +0200 Subject: [PATCH 023/164] Bump version to 2024.5.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eb46817bd34..31dc771d966 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 4dd5653f8ce..ac3c84d67f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0" +version = "2024.5.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f4830216a8b5e27fe204db1d97df49791ed644b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 4 May 2024 20:17:21 +0200 Subject: [PATCH 024/164] Add workaround for data entry flow show progress (#116704) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/data_entry_flow.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index f628879a7fd..0bd494992b6 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -352,6 +352,18 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): ) -> _FlowResultT: """Continue a data entry flow.""" result: _FlowResultT | None = None + + # Workaround for flow handlers which have not been upgraded to pass a show + # progress task, needed because of the change to eager tasks in HA Core 2024.5, + # can be removed in HA Core 2024.8. + flow = self._progress.get(flow_id) + if flow and flow.deprecated_show_progress: + if (cur_step := flow.cur_step) and cur_step[ + "type" + ] == FlowResultType.SHOW_PROGRESS: + # Allow the progress task to finish before we call the flow handler + await asyncio.sleep(0) + while not result or result["type"] == FlowResultType.SHOW_PROGRESS_DONE: result = await self._async_configure(flow_id, user_input) flow = self._progress.get(flow_id) From 17c5aa287190c4d61c88a4b7ed0abe5be6f04c65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 May 2024 17:35:44 -0500 Subject: [PATCH 025/164] Improve logging of _TrackPointUTCTime objects (#116711) --- homeassistant/helpers/event.py | 14 ++++++++++---- tests/helpers/test_event.py | 18 ++++++++++++++++++ tests/ignore_uncaught_exceptions.py | 6 ++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5cffe992c0d..5c026064c28 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1436,12 +1436,18 @@ class _TrackPointUTCTime: """Initialize track job.""" loop = self.hass.loop self._cancel_callback = loop.call_at( - loop.time() + self.expected_fire_timestamp - time.time(), self._run_action + loop.time() + self.expected_fire_timestamp - time.time(), self ) @callback - def _run_action(self) -> None: - """Call the action.""" + def __call__(self) -> None: + """Call the action. + + We implement this as __call__ so when debug logging logs the object + it shows the name of the job. This is especially helpful when asyncio + debug logging is enabled as we can see the name of the job that is + being called that is blocking the event loop. + """ # Depending on the available clock support (including timer hardware # and the OS kernel) it can happen that we fire a little bit too early # as measured by utcnow(). That is bad when callbacks have assumptions @@ -1450,7 +1456,7 @@ class _TrackPointUTCTime: if (delta := (self.expected_fire_timestamp - time_tracker_timestamp())) > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) loop = self.hass.loop - self._cancel_callback = loop.call_at(loop.time() + delta, self._run_action) + self._cancel_callback = loop.call_at(loop.time() + delta, self) return self.hass.async_run_hass_job(self.job, self.utc_point_in_time) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 07228abcc2c..a6fad968eac 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4819,3 +4819,21 @@ async def test_track_state_change_deprecated( "of `async_track_state_change_event` which is deprecated and " "will be removed in Home Assistant 2025.5. Please report this issue." ) in caplog.text + + +async def test_track_point_in_time_repr( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test track point in time.""" + + @ha.callback + def _raise_exception(_): + raise RuntimeError("something happened and its poorly described") + + async_track_point_in_utc_time(hass, _raise_exception, dt_util.utcnow()) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Exception in callback _TrackPointUTCTime" in caplog.text + assert "._raise_exception" in caplog.text + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index 3be2093057b..aaf6cbe3efe 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -7,6 +7,12 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [ "tests.test_runner", "test_unhandled_exception_traceback", ), + ( + # This test explicitly throws an uncaught exception + # and should not be removed. + "tests.helpers.test_event", + "test_track_point_in_time_repr", + ), ( "test_homeassistant_bridge", "test_homeassistant_bridge_fan_setup", From 6d537e2a6690b0593d0d8a093eb22f9376167e48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 May 2024 10:29:00 -0500 Subject: [PATCH 026/164] Bump aiohttp-isal to 0.3.1 (#116720) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 800e4d90009..024cdd3eab7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.1.0 aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-isal==0.2.0 +aiohttp-isal==0.3.1 aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 diff --git a/pyproject.toml b/pyproject.toml index ac3c84d67f6..51023a501e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "aiohttp_cors==0.7.0", "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-isal==0.2.0", + "aiohttp-isal==0.3.1", "astral==2.2", "async-interrupt==1.1.1", "attrs==23.2.0", diff --git a/requirements.txt b/requirements.txt index 44c60aec07a..df001251a04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-isal==0.2.0 +aiohttp-isal==0.3.1 astral==2.2 async-interrupt==1.1.1 attrs==23.2.0 From bbb94d9e1780b01a52be7c3618febb742b9a779b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 3 May 2024 23:54:27 +0200 Subject: [PATCH 027/164] Fix Bosch-SHC switch state (#116721) --- homeassistant/components/bosch_shc/switch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index e6ccd2aa9aa..58370a120f2 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -43,21 +43,21 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = { "smartplug": SHCSwitchEntityDescription( key="smartplug", device_class=SwitchDeviceClass.OUTLET, - on_key="state", + on_key="switchstate", on_value=SHCSmartPlug.PowerSwitchService.State.ON, should_poll=False, ), "smartplugcompact": SHCSwitchEntityDescription( key="smartplugcompact", device_class=SwitchDeviceClass.OUTLET, - on_key="state", + on_key="switchstate", on_value=SHCSmartPlugCompact.PowerSwitchService.State.ON, should_poll=False, ), "lightswitch": SHCSwitchEntityDescription( key="lightswitch", device_class=SwitchDeviceClass.SWITCH, - on_key="state", + on_key="switchstate", on_value=SHCLightSwitch.PowerSwitchService.State.ON, should_poll=False, ), From f068b8cdb8accb8a1b19115004021ef321035cb8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 4 May 2024 17:29:42 +0200 Subject: [PATCH 028/164] Remove suggested UoM from Opower (#116728) --- homeassistant/components/opower/sensor.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 9f467dce1c6..c75ffb9614b 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -69,7 +69,6 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Current bill electric cost to date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", - suggested_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.cost_to_date, @@ -79,7 +78,6 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Current bill electric forecasted cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", - suggested_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.forecasted_cost, @@ -89,7 +87,6 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Typical monthly electric cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", - suggested_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.typical_cost, @@ -101,7 +98,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Current bill gas usage to date", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, - suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.usage_to_date, @@ -111,7 +107,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Current bill gas forecasted usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, - suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.forecasted_usage, @@ -121,7 +116,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Typical monthly gas usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, - suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.typical_usage, @@ -131,7 +125,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Current bill gas cost to date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", - suggested_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.cost_to_date, @@ -141,7 +134,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Current bill gas forecasted cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", - suggested_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.forecasted_cost, @@ -151,7 +143,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Typical monthly gas cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", - suggested_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.typical_cost, From 57bbd105171da4a79602777ceffbbb17f15faa4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 May 2024 15:39:45 -0500 Subject: [PATCH 029/164] Refactor statistics to avoid creating tasks (#116743) --- homeassistant/components/statistics/sensor.py | 103 ++++++++++-------- 1 file changed, 58 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 713a8d3e894..fef10f7296f 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -285,6 +285,9 @@ async def async_setup_platform( class StatisticsSensor(SensorEntity): """Representation of a Statistics sensor.""" + _attr_should_poll = False + _attr_icon = ICON + def __init__( self, source_entity_id: str, @@ -298,9 +301,7 @@ class StatisticsSensor(SensorEntity): percentile: int, ) -> None: """Initialize the Statistics sensor.""" - self._attr_icon: str = ICON self._attr_name: str = name - self._attr_should_poll: bool = False self._attr_unique_id: str | None = unique_id self._source_entity_id: str = source_entity_id self.is_binary: bool = ( @@ -326,35 +327,37 @@ class StatisticsSensor(SensorEntity): self._update_listener: CALLBACK_TYPE | None = None + @callback + def _async_stats_sensor_state_listener( + self, + event: Event[EventStateChangedData], + ) -> None: + """Handle the sensor state changes.""" + if (new_state := event.data["new_state"]) is None: + return + self._add_state_to_queue(new_state) + self._async_purge_update_and_schedule() + self.async_write_ha_state() + + @callback + def _async_stats_sensor_startup(self, _: HomeAssistant) -> None: + """Add listener and get recorded state.""" + _LOGGER.debug("Startup for %s", self.entity_id) + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._source_entity_id], + self._async_stats_sensor_state_listener, + ) + ) + if "recorder" in self.hass.config.components: + self.hass.async_create_task(self._initialize_from_database()) + async def async_added_to_hass(self) -> None: """Register callbacks.""" - - @callback - def async_stats_sensor_state_listener( - event: Event[EventStateChangedData], - ) -> None: - """Handle the sensor state changes.""" - if (new_state := event.data["new_state"]) is None: - return - self._add_state_to_queue(new_state) - self.async_schedule_update_ha_state(True) - - async def async_stats_sensor_startup(_: HomeAssistant) -> None: - """Add listener and get recorded state.""" - _LOGGER.debug("Startup for %s", self.entity_id) - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._source_entity_id], - async_stats_sensor_state_listener, - ) - ) - - if "recorder" in self.hass.config.components: - self.hass.async_create_task(self._initialize_from_database()) - - self.async_on_remove(async_at_start(self.hass, async_stats_sensor_startup)) + self.async_on_remove( + async_at_start(self.hass, self._async_stats_sensor_startup) + ) def _add_state_to_queue(self, new_state: State) -> None: """Add the state to the queue.""" @@ -499,7 +502,8 @@ class StatisticsSensor(SensorEntity): self.ages.popleft() self.states.popleft() - def _next_to_purge_timestamp(self) -> datetime | None: + @callback + def _async_next_to_purge_timestamp(self) -> datetime | None: """Find the timestamp when the next purge would occur.""" if self.ages and self._samples_max_age: if self.samples_keep_last and len(self.ages) == 1: @@ -521,6 +525,10 @@ class StatisticsSensor(SensorEntity): async def async_update(self) -> None: """Get the latest data and updates the states.""" + self._async_purge_update_and_schedule() + + def _async_purge_update_and_schedule(self) -> None: + """Purge old states, update the sensor and schedule the next update.""" _LOGGER.debug("%s: updating statistics", self.entity_id) if self._samples_max_age is not None: self._purge_old_states(self._samples_max_age) @@ -531,23 +539,28 @@ class StatisticsSensor(SensorEntity): # If max_age is set, ensure to update again after the defined interval. # By basing updates off the timestamps of sampled data we avoid updating # when none of the observed entities change. - if timestamp := self._next_to_purge_timestamp(): + if timestamp := self._async_next_to_purge_timestamp(): _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp) - if self._update_listener: - self._update_listener() - self._update_listener = None - - @callback - def _scheduled_update(now: datetime) -> None: - """Timer callback for sensor update.""" - _LOGGER.debug("%s: executing scheduled update", self.entity_id) - self.async_schedule_update_ha_state(True) - self._update_listener = None - + self._async_cancel_update_listener() self._update_listener = async_track_point_in_utc_time( - self.hass, _scheduled_update, timestamp + self.hass, self._async_scheduled_update, timestamp ) + @callback + def _async_cancel_update_listener(self) -> None: + """Cancel the scheduled update listener.""" + if self._update_listener: + self._update_listener() + self._update_listener = None + + @callback + def _async_scheduled_update(self, now: datetime) -> None: + """Timer callback for sensor update.""" + _LOGGER.debug("%s: executing scheduled update", self.entity_id) + self._async_cancel_update_listener() + self._async_purge_update_and_schedule() + self.async_write_ha_state() + def _fetch_states_from_database(self) -> list[State]: """Fetch the states from the database.""" _LOGGER.debug("%s: initializing values from the database", self.entity_id) @@ -589,8 +602,8 @@ class StatisticsSensor(SensorEntity): for state in reversed(states): self._add_state_to_queue(state) - self.async_schedule_update_ha_state(True) - + self._async_purge_update_and_schedule() + self.async_write_ha_state() _LOGGER.debug("%s: initializing from database completed", self.entity_id) def _update_attributes(self) -> None: From 18bcc61427fddfb2d853a5757a11415cab7f41c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 May 2024 10:26:14 -0500 Subject: [PATCH 030/164] Bump bluetooth-adapters to 0.19.2 (#116785) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 754e8faf996..fe5867191e2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "requirements": [ "bleak==0.21.1", "bleak-retry-connector==3.5.0", - "bluetooth-adapters==0.19.1", + "bluetooth-adapters==0.19.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 024cdd3eab7..4fd8ebccd7e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ awesomeversion==24.2.0 bcrypt==4.1.2 bleak-retry-connector==3.5.0 bleak==0.21.1 -bluetooth-adapters==0.19.1 +bluetooth-adapters==0.19.2 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6f741478f20..462d33d69d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -579,7 +579,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.19.1 +bluetooth-adapters==0.19.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14951b8a6ec..5233e7e70f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -494,7 +494,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.19.1 +bluetooth-adapters==0.19.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 From 79460cb017f6c3d2985f78d502728b70cdded0d6 Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Sat, 4 May 2024 20:16:58 +0200 Subject: [PATCH 031/164] fix UnboundLocalError on modified_statistic_ids in compile_statistics (#116795) Co-authored-by: J. Nick Koston --- homeassistant/components/recorder/statistics.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 41cf4e22b53..572731a9fed 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -485,6 +485,12 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - The actual calculation is delegated to the platforms. """ + # Define modified_statistic_ids outside of the "with" statement as + # _compile_statistics may raise and be trapped by + # filter_unique_constraint_integrity_error which would make + # modified_statistic_ids unbound. + modified_statistic_ids: set[str] | None = None + # Return if we already have 5-minute statistics for the requested period with session_scope( session=instance.get_session(), From ad7688197ff6ae4869bb29b741360f5c3759ba71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 May 2024 05:09:57 -0500 Subject: [PATCH 032/164] Ensure all synology_dsm coordinators handle expired sessions (#116796) * Ensure all synology_dsm coordinators handle expired sessions * Ensure all synology_dsm coordinators handle expired sessions * Ensure all synology_dsm coordinators handle expired sessions * handle cancellation * add a debug log message --------- Co-authored-by: mib1185 --- .../components/synology_dsm/__init__.py | 6 ++- .../components/synology_dsm/common.py | 28 ++++++++++- .../components/synology_dsm/coordinator.py | 50 +++++++++++++------ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 2748b27c93d..6598ed304f7 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -7,6 +7,7 @@ import logging from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera +from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL @@ -69,7 +70,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await api.async_setup() except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err: raise_config_entry_auth_error(err) - except SYNOLOGY_CONNECTION_EXCEPTIONS as err: + except (*SYNOLOGY_CONNECTION_EXCEPTIONS, SynologyDSMNotLoggedInException) as err: + # SynologyDSMNotLoggedInException may be raised even if the user is + # logged in because the session may have expired, and we need to retry + # the login later. if err.args[0] and isinstance(err.args[0], dict): details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) else: diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 04e8ae29ceb..c871dd7b705 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from contextlib import suppress import logging @@ -82,6 +83,31 @@ class SynoApi: self._with_upgrade = True self._with_utilisation = True + self._login_future: asyncio.Future[None] | None = None + + async def async_login(self) -> None: + """Login to the Synology DSM API. + + This function will only login once if called multiple times + by multiple different callers. + + If a login is already in progress, the function will await the + login to complete before returning. + """ + if self._login_future: + return await self._login_future + + self._login_future = self._hass.loop.create_future() + try: + await self.dsm.login() + self._login_future.set_result(None) + except BaseException as err: + if not self._login_future.done(): + self._login_future.set_exception(err) + raise + finally: + self._login_future = None + async def async_setup(self) -> None: """Start interacting with the NAS.""" session = async_get_clientsession(self._hass, self._entry.data[CONF_VERIFY_SSL]) @@ -95,7 +121,7 @@ class SynoApi: timeout=self._entry.options.get(CONF_TIMEOUT) or 10, device_token=self._entry.data.get(CONF_DEVICE_TOKEN), ) - await self.dsm.login() + await self.async_login() # check if surveillance station is used self._with_surveillance_station = bool( diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 34886828a58..52a3e1de1eb 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable, Coroutine from datetime import timedelta import logging -from typing import Any, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( @@ -30,6 +31,36 @@ _LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT") +_T = TypeVar("_T", bound="SynologyDSMUpdateCoordinator") +_P = ParamSpec("_P") + + +def async_re_login_on_expired( + func: Callable[Concatenate[_T, _P], Awaitable[_DataT]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _DataT]]: + """Define a wrapper to re-login when expired.""" + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _DataT: + for attempts in range(2): + try: + return await func(self, *args, **kwargs) + except SynologyDSMNotLoggedInException: + # If login is expired, try to login again + _LOGGER.debug("login is expired, try to login again") + try: + await self.api.async_login() + except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err: + raise_config_entry_auth_error(err) + if attempts == 0: + continue + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + raise UpdateFailed("Unknown error when communicating with API") + + return _async_wrap + + class SynologyDSMUpdateCoordinator(DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" @@ -72,6 +103,7 @@ class SynologyDSMSwitchUpdateCoordinator( assert info is not None self.version = info["data"]["CMSMinVersion"] + @async_re_login_on_expired async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Fetch all data from api.""" surveillance_station = self.api.surveillance_station @@ -102,21 +134,10 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): ), ) + @async_re_login_on_expired async def _async_update_data(self) -> None: """Fetch all data from api.""" - for attempts in range(2): - try: - await self.api.async_update() - except SynologyDSMNotLoggedInException: - # If login is expired, try to login again - try: - await self.api.dsm.login() - except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err: - raise_config_entry_auth_error(err) - if attempts == 0: - continue - except SYNOLOGY_CONNECTION_EXCEPTIONS as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err + await self.api.async_update() class SynologyDSMCameraUpdateCoordinator( @@ -133,6 +154,7 @@ class SynologyDSMCameraUpdateCoordinator( """Initialize DataUpdateCoordinator for cameras.""" super().__init__(hass, entry, api, timedelta(seconds=30)) + @async_re_login_on_expired async def _async_update_data(self) -> dict[str, dict[int, SynoCamera]]: """Fetch all camera data from api.""" surveillance_station = self.api.surveillance_station From dbe303d95ee34ace5051d45916e58c15b13a19cf Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 4 May 2024 20:18:26 +0200 Subject: [PATCH 033/164] Fix IMAP config entry setup (#116797) --- homeassistant/components/imap/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 62ed4d42a07..6f93ce71d84 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -75,7 +75,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_FOLDER, default="INBOX"): str, vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, # The default for new entries is to not include text and headers - vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): cv.ensure_list, + vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR, } ) CONFIG_SCHEMA_ADVANCED = { From ae28c604e5dfb516f05581bff60bfabe18e4f788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 May 2024 15:37:10 -0500 Subject: [PATCH 034/164] Fix airthings-ble data drop outs when Bluetooth connection is flakey (#116805) * Fix airthings-ble data drop outs when Bluetooth adapter is flakey fixes #116770 * add missing file * update --- homeassistant/components/airthings_ble/__init__.py | 8 +++++++- homeassistant/components/airthings_ble/const.py | 2 ++ homeassistant/components/airthings_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index 39617a8a019..219a384bae0 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MAX_RETRIES_AFTER_STARTUP PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -61,6 +61,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + # Once its setup and we know we are not going to delay + # the startup of Home Assistant, we can set the max attempts + # to a higher value. If the first connection attempt fails, + # Home Assistant's built-in retry logic will take over. + airthings.set_max_attempts(MAX_RETRIES_AFTER_STARTUP) + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py index 96372919e70..fdfebea8bff 100644 --- a/homeassistant/components/airthings_ble/const.py +++ b/homeassistant/components/airthings_ble/const.py @@ -7,3 +7,5 @@ VOLUME_BECQUEREL = "Bq/m³" VOLUME_PICOCURIE = "pCi/L" DEFAULT_SCAN_INTERVAL = 300 + +MAX_RETRIES_AFTER_STARTUP = 5 diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index d93e3a0b8cb..b86bc314819 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.8.0"] + "requirements": ["airthings-ble==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 462d33d69d0..e3f2bd0e36c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -413,7 +413,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.8.0 +airthings-ble==0.9.0 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5233e7e70f8..27e70c28916 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,7 +386,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.8.0 +airthings-ble==0.9.0 # homeassistant.components.airthings airthings-cloud==0.2.0 From ad5e0949b6b10cb10fe9909e0799bc2d881c664a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 May 2024 20:09:38 -0400 Subject: [PATCH 035/164] Hide conversation agents that are exposed as agent entities (#116813) --- homeassistant/components/conversation/http.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index beda7ba1550..e582dacf284 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -142,6 +142,9 @@ async def websocket_list_agents( agent = manager.async_get_agent(agent_info.id) assert agent is not None + if isinstance(agent, ConversationEntity): + continue + supported_languages = agent.supported_languages if language and supported_languages != MATCH_ALL: supported_languages = language_util.matches( From c049888b00094f17c0c4e8e2c88dc2ac15c9efa0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 May 2024 08:43:39 -0500 Subject: [PATCH 036/164] Fix non-thread-safe state write in lutron event (#116829) fixes #116746 --- homeassistant/components/lutron/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index 710f942a006..f231c33a296 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -106,4 +106,4 @@ class LutronEventEntity(LutronKeypad, EventEntity): } self.hass.bus.fire("lutron_event", data) self._trigger_event(action) - self.async_write_ha_state() + self.schedule_update_ha_state() From 421f74cd7f3f2b20c8e402402086571e5286a79e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 May 2024 15:07:18 +0200 Subject: [PATCH 037/164] Increase default timeout to 30 seconds in Synology DSM (#116836) increase default timeout to 30s and use it consequently --- homeassistant/components/synology_dsm/common.py | 3 ++- homeassistant/components/synology_dsm/config_flow.py | 4 +++- homeassistant/components/synology_dsm/const.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index c871dd7b705..91c4cfc4ae2 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -39,6 +39,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_DEVICE_TOKEN, + DEFAULT_TIMEOUT, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, SYNOLOGY_CONNECTION_EXCEPTIONS, @@ -118,7 +119,7 @@ class SynoApi: self._entry.data[CONF_USERNAME], self._entry.data[CONF_PASSWORD], self._entry.data[CONF_SSL], - timeout=self._entry.options.get(CONF_TIMEOUT) or 10, + timeout=self._entry.options.get(CONF_TIMEOUT) or DEFAULT_TIMEOUT, device_token=self._entry.data.get(CONF_DEVICE_TOKEN), ) await self.async_login() diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 785baa50b29..d6c0c6fe3e8 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -179,7 +179,9 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): port = DEFAULT_PORT session = async_get_clientsession(self.hass, verify_ssl) - api = SynologyDSM(session, host, port, username, password, use_ssl, timeout=30) + api = SynologyDSM( + session, host, port, username, password, use_ssl, timeout=DEFAULT_TIMEOUT + ) errors = {} try: diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 140e07e975b..35d3008b416 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -40,7 +40,7 @@ DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options DEFAULT_SCAN_INTERVAL = 15 # min -DEFAULT_TIMEOUT = 10 # sec +DEFAULT_TIMEOUT = 30 # sec DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED ENTITY_UNIT_LOAD = "load" From 834c2e2a09730549e8d7fddc0aa90bdb43e3a99f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 May 2024 15:09:26 +0200 Subject: [PATCH 038/164] Avoid duplicate data fetch during Synologs DSM setup (#116839) don't do first refresh of central coordinator, is already done by api.setup before --- homeassistant/components/synology_dsm/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 6598ed304f7..d42dacca638 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -90,12 +90,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator_central = SynologyDSMCentralUpdateCoordinator(hass, entry, api) - await coordinator_central.async_config_entry_first_refresh() available_apis = api.dsm.apis - # The central coordinator needs to be refreshed first since - # the next two rely on data from it coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None = None if api.surveillance_station is not None: coordinator_cameras = SynologyDSMCameraUpdateCoordinator(hass, entry, api) From 73eabe821cb80ca1fe2a54df832c2fa1c6dfa519 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 5 May 2024 06:44:40 -0700 Subject: [PATCH 039/164] Bump androidtvremote2 to v0.0.15 (#116844) --- homeassistant/components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index f45dee34afe..915586b3879 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.14"], + "requirements": ["androidtvremote2==0.0.15"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e3f2bd0e36c..be856c1fa85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -437,7 +437,7 @@ amcrest==1.9.8 androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.14 +androidtvremote2==0.0.15 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27e70c28916..80b1c2a345b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ amberelectric==1.1.0 androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.14 +androidtvremote2==0.0.15 # homeassistant.components.anova anova-wifi==0.10.0 From 7c9653e3974ce06c464e5224ef7ec7f64d1bf8f4 Mon Sep 17 00:00:00 2001 From: mletenay Date: Mon, 6 May 2024 01:05:21 +0200 Subject: [PATCH 040/164] Bump goodwe to 0.3.4 (#116849) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 6f1bdd2b449..59c259524c8 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.3.2"] + "requirements": ["goodwe==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index be856c1fa85..bf73d7792e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ glances-api==0.6.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.2 +goodwe==0.3.4 # homeassistant.components.google_mail # homeassistant.components.google_tasks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80b1c2a345b..77802e3d5c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -781,7 +781,7 @@ glances-api==0.6.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.2 +goodwe==0.3.4 # homeassistant.components.google_mail # homeassistant.components.google_tasks From 9533f5b49006c6e5b8b31a247571e95ea0072ba5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 May 2024 15:58:38 -0500 Subject: [PATCH 041/164] Fix non-thread-safe operations in amcrest (#116859) * Fix non-thread-safe operations in amcrest fixes #116850 * fix locking * fix locking * fix locking --- homeassistant/components/amcrest/__init__.py | 95 +++++++++++++++----- homeassistant/components/amcrest/camera.py | 5 +- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index c12aa6d7916..624e0145b86 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -35,7 +35,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -177,7 +177,8 @@ class AmcrestChecker(ApiWrapper): """Return event flag that indicates if camera's API is responding.""" return self._async_wrap_event_flag - def _start_recovery(self) -> None: + @callback + def _async_start_recovery(self) -> None: self.available_flag.clear() self.async_available_flag.clear() async_dispatcher_send( @@ -222,50 +223,98 @@ class AmcrestChecker(ApiWrapper): yield except LoginError as ex: async with self._async_wrap_lock: - self._handle_offline(ex) + self._async_handle_offline(ex) raise except AmcrestError: async with self._async_wrap_lock: - self._handle_error() + self._async_handle_error() raise async with self._async_wrap_lock: - self._set_online() + self._async_set_online() - def _handle_offline(self, ex: Exception) -> None: + def _handle_offline_thread_safe(self, ex: Exception) -> bool: + """Handle camera offline status shared between threads and event loop. + + Returns if the camera was online as a bool. + """ with self._wrap_lock: was_online = self.available was_login_err = self._wrap_login_err self._wrap_login_err = True if not was_login_err: _LOGGER.error("%s camera offline: Login error: %s", self._wrap_name, ex) - if was_online: - self._start_recovery() + return was_online - def _handle_error(self) -> None: + def _handle_offline(self, ex: Exception) -> None: + """Handle camera offline status from a thread.""" + if self._handle_offline_thread_safe(ex): + self._hass.loop.call_soon_threadsafe(self._async_start_recovery) + + @callback + def _async_handle_offline(self, ex: Exception) -> None: + if self._handle_offline_thread_safe(ex): + self._async_start_recovery() + + def _handle_error_thread_safe(self) -> bool: + """Handle camera error status shared between threads and event loop. + + Returns if the camera was online and is now offline as + a bool. + """ with self._wrap_lock: was_online = self.available errs = self._wrap_errors = self._wrap_errors + 1 offline = not self.available _LOGGER.debug("%s camera errs: %i", self._wrap_name, errs) - if was_online and offline: - _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) - self._start_recovery() + return was_online and offline - def _set_online(self) -> None: + def _handle_error(self) -> None: + """Handle camera error status from a thread.""" + if self._handle_error_thread_safe(): + _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) + self._hass.loop.call_soon_threadsafe(self._async_start_recovery) + + @callback + def _async_handle_error(self) -> None: + """Handle camera error status from the event loop.""" + if self._handle_error_thread_safe(): + _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) + self._async_start_recovery() + + def _set_online_thread_safe(self) -> bool: + """Set camera online status shared between threads and event loop. + + Returns if the camera was offline as a bool. + """ with self._wrap_lock: was_offline = not self.available self._wrap_errors = 0 self._wrap_login_err = False - if was_offline: - assert self._unsub_recheck is not None - self._unsub_recheck() - self._unsub_recheck = None - _LOGGER.error("%s camera back online", self._wrap_name) - self.available_flag.set() - self.async_available_flag.set() - async_dispatcher_send( - self._hass, service_signal(SERVICE_UPDATE, self._wrap_name) - ) + return was_offline + + def _set_online(self) -> None: + """Set camera online status from a thread.""" + if self._set_online_thread_safe(): + self._hass.loop.call_soon_threadsafe(self._async_signal_online) + + @callback + def _async_set_online(self) -> None: + """Set camera online status from the event loop.""" + if self._set_online_thread_safe(): + self._async_signal_online() + + @callback + def _async_signal_online(self) -> None: + """Signal that camera is back online.""" + assert self._unsub_recheck is not None + self._unsub_recheck() + self._unsub_recheck = None + _LOGGER.error("%s camera back online", self._wrap_name) + self.available_flag.set() + self.async_available_flag.set() + async_dispatcher_send( + self._hass, service_signal(SERVICE_UPDATE, self._wrap_name) + ) async def _wrap_test_online(self, now: datetime) -> None: """Test if camera is back online.""" diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 1cbf5af4b70..a55f9c81e64 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -16,7 +16,7 @@ import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, @@ -325,7 +325,8 @@ class AmcrestCam(Camera): # Other Entity method overrides - async def async_on_demand_update(self) -> None: + @callback + def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) From ed6788ca3fdce964df5948ed69422db4de457b77 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 5 May 2024 12:54:17 -0400 Subject: [PATCH 042/164] fix radarr coordinator updates (#116874) --- homeassistant/components/radarr/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 0580fdcc020..47a1862b8ae 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -46,7 +46,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry - update_interval = timedelta(seconds=30) + _update_interval = timedelta(seconds=30) def __init__( self, @@ -59,7 +59,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=self.update_interval, + update_interval=self._update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -133,7 +133,7 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): """Calendar update coordinator.""" - update_interval = timedelta(hours=1) + _update_interval = timedelta(hours=1) def __init__( self, From ab113570c3a063870ae297345eb4d3e474823ba9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 May 2024 14:32:37 +0200 Subject: [PATCH 043/164] Fix initial mqtt subcribe cooldown timeout (#116904) --- homeassistant/components/mqtt/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 4fa9f4a1d49..4b05442d71b 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -84,7 +84,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) DISCOVERY_COOLDOWN = 5 -INITIAL_SUBSCRIBE_COOLDOWN = 1.0 +INITIAL_SUBSCRIBE_COOLDOWN = 3.0 SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 @@ -891,6 +891,7 @@ class MQTT: qos=birth_message.qos, retain=birth_message.retain, ) + _LOGGER.info("MQTT client initialized, birth message sent") @callback def _async_mqtt_on_connect( @@ -950,6 +951,7 @@ class MQTT: name="mqtt re-subscribe", ) self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) + _LOGGER.info("MQTT client initialized") self._async_connection_result(True) From 6b93f8d997f2b3986916f676917bfb29a3444a88 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 May 2024 17:19:04 +0200 Subject: [PATCH 044/164] Bump version to 2024.5.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 31dc771d966..e9e1231712e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 51023a501e6..887083304cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.1" +version = "2024.5.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From eb6ccea8aa65ebf33711fbdd649832c084d26249 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 6 May 2024 18:40:01 +0200 Subject: [PATCH 045/164] Update frontend to 20240501.1 (#116939) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6abe8df1d7c..1c4245d93b6 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==20240501.0"] + "requirements": ["home-assistant-frontend==20240501.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4fd8ebccd7e..0f69f7d63c9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.1 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240501.0 +home-assistant-frontend==20240501.1 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index bf73d7792e7..e4c84b11ab8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240501.0 +home-assistant-frontend==20240501.1 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77802e3d5c7..e9dc44b3765 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240501.0 +home-assistant-frontend==20240501.1 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From 624baebbaa2c4de2f636f61d17597c6f7270f1fc Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 7 May 2024 04:08:12 -0400 Subject: [PATCH 046/164] Fix Sonos select_source timeout error (#115640) --- .../components/sonos/media_player.py | 12 +- homeassistant/components/sonos/strings.json | 5 + tests/components/sonos/conftest.py | 15 +- .../sonos/fixtures/sonos_favorites.json | 38 +++++ tests/components/sonos/test_media_player.py | 159 +++++++++++++++++- 5 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 tests/components/sonos/fixtures/sonos_favorites.json diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 35c6be3fa6b..e9fbb152b7a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -39,7 +39,7 @@ from homeassistant.components.plex.services import process_plex_payload from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -432,7 +432,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): fav = [fav for fav in self.speaker.favorites if fav.title == name] if len(fav) != 1: - return + raise ServiceValidationError( + translation_domain=SONOS_DOMAIN, + translation_key="invalid_favorite", + translation_placeholders={ + "name": name, + }, + ) src = fav.pop() self._play_favorite(src) @@ -445,7 +451,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): MUSIC_SRC_RADIO, MUSIC_SRC_LINE_IN, ]: - soco.play_uri(uri, title=favorite.title) + soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT) else: soco.clear_queue() soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT) diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 6f45195c46b..6521302b007 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -173,5 +173,10 @@ } } } + }, + "exceptions": { + "invalid_favorite": { + "message": "Could not find a Sonos favorite: {name}" + } } } diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 0eb9b497fbd..15f371f272c 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from soco import SoCo from soco.alarms import Alarms +from soco.data_structures import DidlFavorite, SearchResult from soco.events_base import Event as SonosEvent from homeassistant.components import ssdp, zeroconf @@ -17,7 +18,7 @@ from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture class SonosMockEventListener: @@ -304,6 +305,14 @@ def config_fixture(): return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.2"]}}} +@pytest.fixture(name="sonos_favorites") +def sonos_favorites_fixture() -> SearchResult: + """Create sonos favorites fixture.""" + favorites = load_json_value_fixture("sonos_favorites.json", "sonos") + favorite_list = [DidlFavorite.from_dict(fav) for fav in favorites] + return SearchResult(favorite_list, "favorites", 3, 3, 1) + + class MockMusicServiceItem: """Mocks a Soco MusicServiceItem.""" @@ -408,10 +417,10 @@ def mock_get_music_library_information( @pytest.fixture(name="music_library") -def music_library_fixture(): +def music_library_fixture(sonos_favorites: SearchResult) -> Mock: """Create music_library fixture.""" music_library = MagicMock() - music_library.get_sonos_favorites.return_value.update_id = 1 + music_library.get_sonos_favorites.return_value = sonos_favorites music_library.browse_by_idstring = mock_browse_by_idstring music_library.get_music_library_information = mock_get_music_library_information return music_library diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json new file mode 100644 index 00000000000..21ee68f4872 --- /dev/null +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -0,0 +1,38 @@ +[ + { + "title": "66 - Watercolors", + "parent_id": "FV:2", + "item_id": "FV:2/4", + "resource_meta_data": "66 - Watercolorsobject.item.audioItem.audioBroadcastSA_RINCON9479_X_#Svc9479-99999999-Token", + "resources": [ + { + "uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc", + "protocol_info": "a:b:c:d" + } + ] + }, + { + "title": "James Taylor Radio", + "parent_id": "FV:2", + "item_id": "FV:2/13", + "resource_meta_data": "James Taylor Radioobject.item.audioItem.audioBroadcast.#stationSA_RINCON60423_X_#Svc60423-99999999-Token", + "resources": [ + { + "uri": "x-sonosapi-radio:ST%3aetc", + "protocol_info": "a:b:c:d" + } + ] + }, + { + "title": "1984", + "parent_id": "FV:2", + "item_id": "FV:2/8", + "resource_meta_data": "1984object.container.album.musicAlbumRINCON_AssociatedZPUDN", + "resources": [ + { + "uri": "x-rincon-playlist:RINCON_test#A:ALBUMARTIST/Aerosmith/1984", + "protocol_info": "a:b:c:d" + } + ] + } +] diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 976d3480429..9fb8444a696 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,6 +1,7 @@ """Tests for the Sonos Media Player platform.""" import logging +from typing import Any import pytest @@ -9,10 +10,15 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaPlayerEnqueue, ) -from homeassistant.components.media_player.const import ATTR_MEDIA_ENQUEUE +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ENQUEUE, + SERVICE_SELECT_SOURCE, +) +from homeassistant.components.sonos.const import SOURCE_LINEIN, SOURCE_TV from homeassistant.components.sonos.media_player import LONG_SERVICE_TIMEOUT from homeassistant.const import STATE_IDLE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, CONNECTION_UPNP, @@ -272,3 +278,154 @@ async def test_play_media_music_library_playlist_dne( assert soco_mock.play_uri.call_count == 0 assert media_content_id in caplog.text assert "playlist" in caplog.text + + +@pytest.mark.parametrize( + ("source", "result"), + [ + ( + SOURCE_LINEIN, + { + "switch_to_line_in": 1, + }, + ), + ( + SOURCE_TV, + { + "switch_to_tv": 1, + }, + ), + ], +) +async def test_select_source_line_in_tv( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + source: str, + result: dict[str, Any], +) -> None: + """Test the select_source method with a variety of inputs.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": source, + }, + blocking=True, + ) + assert soco_mock.switch_to_line_in.call_count == result.get("switch_to_line_in", 0) + assert soco_mock.switch_to_tv.call_count == result.get("switch_to_tv", 0) + + +@pytest.mark.parametrize( + ("source", "result"), + [ + ( + "James Taylor Radio", + { + "play_uri": 1, + "play_uri_uri": "x-sonosapi-radio:ST%3aetc", + "play_uri_title": "James Taylor Radio", + }, + ), + ( + "66 - Watercolors", + { + "play_uri": 1, + "play_uri_uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc", + "play_uri_title": "66 - Watercolors", + }, + ), + ], +) +async def test_select_source_play_uri( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + source: str, + result: dict[str, Any], +) -> None: + """Test the select_source method with a variety of inputs.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": source, + }, + blocking=True, + ) + assert soco_mock.play_uri.call_count == result.get("play_uri") + soco_mock.play_uri.assert_called_with( + result.get("play_uri_uri"), + title=result.get("play_uri_title"), + timeout=LONG_SERVICE_TIMEOUT, + ) + + +@pytest.mark.parametrize( + ("source", "result"), + [ + ( + "1984", + { + "add_to_queue": 1, + "add_to_queue_item_id": "A:ALBUMARTIST/Aerosmith/1984", + "clear_queue": 1, + "play_from_queue": 1, + }, + ), + ], +) +async def test_select_source_play_queue( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + source: str, + result: dict[str, Any], +) -> None: + """Test the select_source method with a variety of inputs.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": source, + }, + blocking=True, + ) + assert soco_mock.clear_queue.call_count == result.get("clear_queue") + assert soco_mock.add_to_queue.call_count == result.get("add_to_queue") + assert soco_mock.add_to_queue.call_args_list[0].args[0].item_id == result.get( + "add_to_queue_item_id" + ) + assert ( + soco_mock.add_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert soco_mock.play_from_queue.call_count == result.get("play_from_queue") + soco_mock.play_from_queue.assert_called_with(0) + + +async def test_select_source_error( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, +) -> None: + """Test the select_source method with a variety of inputs.""" + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": "invalid_source", + }, + blocking=True, + ) + assert "invalid_source" in str(sve.value) + assert "Could not find a Sonos favorite" in str(sve.value) From 57861dc091ec3c5aab0096f53a8461f25d7ada1e Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Tue, 7 May 2024 21:10:04 +0200 Subject: [PATCH 047/164] Update strings for Bring notification service (#116181) update translations --- homeassistant/components/bring/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index e6df885cbbc..5deb0759c17 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -60,8 +60,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Item (Required if message type `Breaking news` selected)", - "description": "Item name to include in a breaking news message e.g. `Breaking news - Please get cilantro!`" + "name": "Article (Required if message type `Urgent Message` selected)", + "description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" } } } @@ -69,10 +69,10 @@ "selector": { "notification_type_selector": { "options": { - "going_shopping": "I'm going shopping! - Last chance for adjustments", - "changed_list": "List changed - Check it out", - "shopping_done": "Shopping done - you can relax", - "urgent_message": "Breaking news - Please get `item`!" + "going_shopping": "I'm going shopping! - Last chance to make changes", + "changed_list": "List updated - Take a look at the articles", + "shopping_done": "Shopping done - The fridge is well stocked", + "urgent_message": "Urgent Message - Please buy `Article name` urgently" } } } From fdc59547e0f02a7799657dffecd12df969f44d01 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 7 May 2024 13:51:10 +0800 Subject: [PATCH 048/164] Bump Yolink api to 0.4.4 (#116967) --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index b7bd1d4784f..5353d5d5b8c 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.3"] + "requirements": ["yolink-api==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4c84b11ab8..f188c7ea248 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2914,7 +2914,7 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.3 +yolink-api==0.4.4 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9dc44b3765..9bec4e50de4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2264,7 +2264,7 @@ yalexs==3.0.1 yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.4.3 +yolink-api==0.4.4 # homeassistant.components.youless youless-api==1.0.1 From bee518dc78cb7c1588e9c54df71bdcae18b86d82 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 7 May 2024 13:56:11 +0200 Subject: [PATCH 049/164] Update jinja2 to 3.1.4 (#116986) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0f69f7d63c9..13ac6119f66 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ home-assistant-frontend==20240501.1 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 -Jinja2==3.1.3 +Jinja2==3.1.4 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.9.15 diff --git a/pyproject.toml b/pyproject.toml index 887083304cf..8fb7839c628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "httpx==0.27.0", "home-assistant-bluetooth==1.12.0", "ifaddr==0.2.0", - "Jinja2==3.1.3", + "Jinja2==3.1.4", "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. diff --git a/requirements.txt b/requirements.txt index df001251a04..9d0cd618b2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ hass-nabucasa==0.78.0 httpx==0.27.0 home-assistant-bluetooth==1.12.0 ifaddr==0.2.0 -Jinja2==3.1.3 +Jinja2==3.1.4 lru-dict==1.3.0 PyJWT==2.8.0 cryptography==42.0.5 From 1a13e1d024aca7bdf1ca95ce9356217310896300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 May 2024 14:41:31 -0500 Subject: [PATCH 050/164] Simplify MQTT subscribe debouncer execution (#117006) --- homeassistant/components/mqtt/client.py | 19 +++++++------------ tests/components/mqtt/test_init.py | 22 +++++++++++----------- tests/components/mqtt/test_mixins.py | 3 +++ 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 4b05442d71b..2ca17f012e4 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -317,7 +317,7 @@ class EnsureJobAfterCooldown: self._loop = asyncio.get_running_loop() self._timeout = timeout self._callback = callback_job - self._task: asyncio.Future | None = None + self._task: asyncio.Task | None = None self._timer: asyncio.TimerHandle | None = None def set_timeout(self, timeout: float) -> None: @@ -332,28 +332,23 @@ class EnsureJobAfterCooldown: _LOGGER.error("%s", ha_error) @callback - def _async_task_done(self, task: asyncio.Future) -> None: + def _async_task_done(self, task: asyncio.Task) -> None: """Handle task done.""" self._task = None @callback - def _async_execute(self) -> None: + def async_execute(self) -> asyncio.Task: """Execute the job.""" if self._task: # Task already running, # so we schedule another run self.async_schedule() - return + return self._task self._async_cancel_timer() self._task = create_eager_task(self._async_job()) self._task.add_done_callback(self._async_task_done) - - async def async_fire(self) -> None: - """Execute the job immediately.""" - if self._task: - await self._task - self._async_execute() + return self._task @callback def _async_cancel_timer(self) -> None: @@ -368,7 +363,7 @@ class EnsureJobAfterCooldown: # We want to reschedule the timer in the future # every time this is called. self._async_cancel_timer() - self._timer = self._loop.call_later(self._timeout, self._async_execute) + self._timer = self._loop.call_later(self._timeout, self.async_execute) async def async_cleanup(self) -> None: """Cleanup any pending task.""" @@ -883,7 +878,7 @@ class MQTT: await self._discovery_cooldown() # Wait for MQTT discovery to cool down # Update subscribe cooldown period to a shorter time # and make sure we flush the debouncer - await self._subscribe_debouncer.async_fire() + await self._subscribe_debouncer.async_execute() self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) await self.async_publish( topic=birth_message.topic, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a1264b52739..b7998274aa0 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2589,19 +2589,19 @@ async def test_subscription_done_when_birth_message_is_sent( mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await mqtt.async_subscribe(hass, "topic/test", record_calls) # We wait until we receive a birth message await asyncio.wait_for(birth.wait(), 1) - # Assert we already have subscribed at the client - # for new config payloads at the time we the birth message is received - assert ("homeassistant/+/+/config", 0) in help_all_subscribe_calls( - mqtt_client_mock - ) - assert ("homeassistant/+/+/+/config", 0) in help_all_subscribe_calls( - mqtt_client_mock - ) - mqtt_client_mock.publish.assert_called_with( - "homeassistant/status", "online", 0, False - ) + + # Assert we already have subscribed at the client + # for new config payloads at the time we the birth message is received + subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) + assert ("homeassistant/+/+/config", 0) in subscribe_calls + assert ("homeassistant/+/+/+/config", 0) in subscribe_calls + mqtt_client_mock.publish.assert_called_with( + "homeassistant/status", "online", 0, False + ) + assert ("topic/test", 0) in subscribe_calls @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 2bcd663c243..e46f0b56c15 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -335,6 +335,9 @@ async def test_default_entity_and_device_name( # Assert that no issues ware registered assert len(events) == 0 + await hass.async_block_till_done() + # Assert that no issues ware registered + assert len(events) == 0 async def test_name_attribute_is_set_or_not( From f34a0dc5ce760164aa00046ace3899162a9bc995 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 7 May 2024 21:19:46 +0200 Subject: [PATCH 051/164] Log an exception mqtt client call back throws (#117028) * Log an exception mqtt client call back throws * Supress exceptions and add test --- homeassistant/components/mqtt/client.py | 22 +++++++++++--- tests/components/mqtt/test_init.py | 39 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 2ca17f012e4..589113d3a9e 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -492,6 +492,9 @@ class MQTT: mqttc.on_subscribe = self._async_mqtt_on_callback mqttc.on_unsubscribe = self._async_mqtt_on_callback + # suppress exceptions at callback + mqttc.suppress_exceptions = True + if will := self.conf.get(CONF_WILL_MESSAGE, DEFAULT_WILL): will_message = PublishMessage(**will) mqttc.will_set( @@ -988,10 +991,21 @@ class MQTT: def _async_mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: - topic = msg.topic - # msg.topic is a property that decodes the topic to a string - # every time it is accessed. Save the result to avoid - # decoding the same topic multiple times. + try: + # msg.topic is a property that decodes the topic to a string + # every time it is accessed. Save the result to avoid + # decoding the same topic multiple times. + topic = msg.topic + except UnicodeDecodeError: + bare_topic: bytes = getattr(msg, "_topic") + _LOGGER.warning( + "Skipping received%s message on invalid topic %s (qos=%s): %s", + " retained" if msg.retain else "", + bare_topic, + msg.qos, + msg.payload[0:8192], + ) + return _LOGGER.debug( "Received%s message on %s (qos=%s): %s", " retained" if msg.retain else "", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b7998274aa0..ec7968ae46b 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -6,8 +6,9 @@ from datetime import datetime, timedelta import json import socket import ssl +import time from typing import Any, TypedDict -from unittest.mock import ANY, MagicMock, call, mock_open, patch +from unittest.mock import ANY, MagicMock, Mock, call, mock_open, patch from freezegun.api import FrozenDateTimeFactory import paho.mqtt.client as paho_mqtt @@ -938,6 +939,42 @@ async def test_receiving_non_utf8_message_gets_logged( ) +async def test_receiving_message_with_non_utf8_topic_gets_logged( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving a non utf8 encoded topic.""" + await mqtt_mock_entry() + await mqtt.async_subscribe(hass, "test-topic", record_calls) + + # Local import to avoid processing MQTT modules when running a testcase + # which does not use MQTT. + + # pylint: disable-next=import-outside-toplevel + from paho.mqtt.client import MQTTMessage + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.mqtt.models import MqttData + + msg = MQTTMessage(topic=b"tasmota/discovery/18FE34E0B760\xcc\x02") + msg.payload = b"Payload" + msg.qos = 2 + msg.retain = True + msg.timestamp = time.monotonic() + + mqtt_data: MqttData = hass.data["mqtt"] + assert mqtt_data.client + mqtt_data.client._async_mqtt_on_message(Mock(), None, msg) + + assert ( + "Skipping received retained message on invalid " + "topic b'tasmota/discovery/18FE34E0B760\\xcc\\x02' " + "(qos=2): b'Payload'" in caplog.text + ) + + async def test_all_subscriptions_run_when_decode_fails( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, From 08ba5304feb801211c26f68c6f3e98da43b22b18 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 8 May 2024 08:38:44 -0500 Subject: [PATCH 052/164] Bump rokuecp to 0.19.3 (#117059) --- homeassistant/components/roku/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index ce4513fb316..fa9823de172 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.19.2"], + "requirements": ["rokuecp==0.19.3"], "ssdp": [ { "st": "roku:ecp", diff --git a/requirements_all.txt b/requirements_all.txt index f188c7ea248..b7147c8f8ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2460,7 +2460,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.2 +rokuecp==0.19.3 # homeassistant.components.romy romy==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bec4e50de4..2f84692c081 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1906,7 +1906,7 @@ rflink==0.0.66 ring-doorbell[listen]==0.8.11 # homeassistant.components.roku -rokuecp==0.19.2 +rokuecp==0.19.3 # homeassistant.components.romy romy==0.0.10 From 9e7e839f03acce12c687a0701be9feac617eb847 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 8 May 2024 14:02:49 +0200 Subject: [PATCH 053/164] Bump pyenphase to 1.20.3 (#117061) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 597d326968d..b3c117556bf 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.20.1"], + "requirements": ["pyenphase==1.20.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index b7147c8f8ec..e39f08d66bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1800,7 +1800,7 @@ pyefergy==22.1.1 pyegps==0.2.5 # homeassistant.components.enphase_envoy -pyenphase==1.20.1 +pyenphase==1.20.3 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f84692c081..2939c4e843e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1405,7 +1405,7 @@ pyefergy==22.1.1 pyegps==0.2.5 # homeassistant.components.enphase_envoy -pyenphase==1.20.1 +pyenphase==1.20.3 # homeassistant.components.everlights pyeverlights==0.1.0 From d40689024a6f389134c141a52eb3f0397040f9ad Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 8 May 2024 17:57:50 -0400 Subject: [PATCH 054/164] Add a missing `addon_name` placeholder to the SkyConnect config flow (#117089) --- .../components/homeassistant_sky_connect/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 9d0aa902cc4..a65aefe96f2 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -95,7 +95,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): _LOGGER.error(err) raise AbortFlow( "addon_set_config_failed", - description_placeholders=self._get_translation_placeholders(), + description_placeholders={ + **self._get_translation_placeholders(), + "addon_name": addon_manager.addon_name, + }, ) from err async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo: From 82fab7df399f2f8d9091575ade306d4fadc9b093 Mon Sep 17 00:00:00 2001 From: mletenay Date: Thu, 9 May 2024 00:08:08 +0200 Subject: [PATCH 055/164] Goodwe Increase max value of export limit to 200% (#117090) --- homeassistant/components/goodwe/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index fc8b3864ae9..d54fb8d8d0c 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -63,7 +63,7 @@ NUMBERS = ( native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, - native_max_value=100, + native_max_value=200, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", From 11f86d9e0b0e54a9ef85e0ede2d212cb7c3ee677 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 May 2024 14:16:08 -0500 Subject: [PATCH 056/164] Improve config entry has already been setup error message (#117091) --- homeassistant/helpers/entity_component.py | 5 ++++- tests/helpers/test_entity_component.py | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index eb54d83e1dd..aae0e2058e4 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -182,7 +182,10 @@ class EntityComponent(Generic[_EntityT]): key = config_entry.entry_id if key in self._platforms: - raise ValueError("Config entry has already been setup!") + raise ValueError( + f"Config entry {config_entry.title} ({key}) for " + f"{platform_type}.{self.domain} has already been setup!" + ) self._platforms[key] = self._async_init_entity_platform( platform_type, diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 60d0774b549..330876aae05 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -3,6 +3,7 @@ from collections import OrderedDict from datetime import timedelta import logging +import re from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time @@ -365,7 +366,13 @@ async def test_setup_entry_fails_duplicate(hass: HomeAssistant) -> None: assert await component.async_setup_entry(entry) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=re.escape( + f"Config entry Mock Title ({entry.entry_id}) for " + "entry_domain.test_domain has already been setup!" + ), + ): await component.async_setup_entry(entry) From b9ed2dab5faa5b1ddf29015376379f0017bb095f Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Wed, 8 May 2024 15:16:20 -0400 Subject: [PATCH 057/164] Fix nws blocking startup (#117094) Co-authored-by: J. Nick Koston --- homeassistant/components/nws/__init__.py | 68 ++++++++++++++++-------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 840d4d917f7..df8cb4c329c 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass import datetime +from functools import partial import logging from pynws import SimpleNWS, call_with_retry @@ -58,36 +60,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nws_data = SimpleNWS(latitude, longitude, api_key, client_session) await nws_data.set_station(station) - async def update_observation() -> None: - """Retrieve recent observations.""" - await call_with_retry( - nws_data.update_observation, - RETRY_INTERVAL, - RETRY_STOP, - start_time=utcnow() - UPDATE_TIME_PERIOD, - ) + def async_setup_update_observation( + retry_interval: datetime.timedelta | float, + retry_stop: datetime.timedelta | float, + ) -> Callable[[], Awaitable[None]]: + async def update_observation() -> None: + """Retrieve recent observations.""" + await call_with_retry( + nws_data.update_observation, + retry_interval, + retry_stop, + start_time=utcnow() - UPDATE_TIME_PERIOD, + ) - async def update_forecast() -> None: - """Retrieve twice-daily forecsat.""" - await call_with_retry( + return update_observation + + def async_setup_update_forecast( + retry_interval: datetime.timedelta | float, + retry_stop: datetime.timedelta | float, + ) -> Callable[[], Awaitable[None]]: + return partial( + call_with_retry, nws_data.update_forecast, - RETRY_INTERVAL, - RETRY_STOP, + retry_interval, + retry_stop, ) - async def update_forecast_hourly() -> None: - """Retrieve hourly forecast.""" - await call_with_retry( + def async_setup_update_forecast_hourly( + retry_interval: datetime.timedelta | float, + retry_stop: datetime.timedelta | float, + ) -> Callable[[], Awaitable[None]]: + return partial( + call_with_retry, nws_data.update_forecast_hourly, - RETRY_INTERVAL, - RETRY_STOP, + retry_interval, + retry_stop, ) + # Don't use retries in setup coordinator_observation = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", - update_method=update_observation, + update_method=async_setup_update_observation(0, 0), update_interval=DEFAULT_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True @@ -98,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, _LOGGER, name=f"NWS forecast station {station}", - update_method=update_forecast, + update_method=async_setup_update_forecast(0, 0), update_interval=DEFAULT_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True @@ -109,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, _LOGGER, name=f"NWS forecast hourly station {station}", - update_method=update_forecast_hourly, + update_method=async_setup_update_forecast_hourly(0, 0), update_interval=DEFAULT_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True @@ -128,6 +143,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator_forecast.async_refresh() await coordinator_forecast_hourly.async_refresh() + # Use retries + coordinator_observation.update_method = async_setup_update_observation( + RETRY_INTERVAL, RETRY_STOP + ) + coordinator_forecast.update_method = async_setup_update_forecast( + RETRY_INTERVAL, RETRY_STOP + ) + coordinator_forecast_hourly.update_method = async_setup_update_forecast_hourly( + RETRY_INTERVAL, RETRY_STOP + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True From c0cd76b3bfdf066e43a41b476a5381cd091ff5c7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 8 May 2024 21:42:11 +0200 Subject: [PATCH 058/164] Make the mqtt discovery update tasks eager and fix race (#117105) * Fix mqtt discovery race for update rapidly followed on creation * Revert unrelated renaming local var --- homeassistant/components/mqtt/mixins.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 63df7c71c09..68173da7297 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -1015,8 +1015,7 @@ class MqttDiscoveryUpdate(Entity): self.hass.async_create_task( _async_process_discovery_update_and_remove( payload, self._discovery_data - ), - eager_start=False, + ) ) elif self._discovery_update: if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: @@ -1025,8 +1024,7 @@ class MqttDiscoveryUpdate(Entity): self.hass.async_create_task( _async_process_discovery_update( payload, self._discovery_update, self._discovery_data - ), - eager_start=False, + ) ) else: # Non-empty, unchanged payload: Ignore to avoid changing states @@ -1059,6 +1057,15 @@ class MqttDiscoveryUpdate(Entity): # rediscovered after a restart await async_remove_discovery_payload(self.hass, self._discovery_data) + @final + async def add_to_platform_finish(self) -> None: + """Finish adding entity to platform.""" + await super().add_to_platform_finish() + # Only send the discovery done after the entity is fully added + # and the state is written to the state machine. + if self._discovery_data is not None: + send_discovery_done(self.hass, self._discovery_data) + @callback def add_to_platform_abort(self) -> None: """Abort adding an entity to a platform.""" @@ -1218,8 +1225,6 @@ class MqttEntity( self._prepare_subscribe_topics() await self._subscribe_topics() await self.mqtt_async_added_to_hass() - if self._discovery_data is not None: - send_discovery_done(self.hass, self._discovery_data) async def mqtt_async_added_to_hass(self) -> None: """Call before the discovery message is acknowledged. From 09490d9e0a6901ca2af822f589038a198d6ee21e Mon Sep 17 00:00:00 2001 From: mletenay Date: Thu, 9 May 2024 00:17:20 +0200 Subject: [PATCH 059/164] Bump goodwe to 0.3.5 (#117115) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 59c259524c8..8506d1fd6af 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.3.4"] + "requirements": ["goodwe==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index e39f08d66bf..1ee861f25bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ glances-api==0.6.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.4 +goodwe==0.3.5 # homeassistant.components.google_mail # homeassistant.components.google_tasks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2939c4e843e..189322bd545 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -781,7 +781,7 @@ glances-api==0.6.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.4 +goodwe==0.3.5 # homeassistant.components.google_mail # homeassistant.components.google_tasks From 1b519a4610ed2b74420a46c85b973a5fe971b538 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 May 2024 00:47:13 -0500 Subject: [PATCH 060/164] Handle tilt position being None in HKC (#117141) --- .../components/homekit_controller/cover.py | 4 ++- .../homekit_controller/test_cover.py | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index ca041d49e11..d0944db38f8 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -212,13 +212,15 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): ) @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt.""" tilt_position = self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT) if not tilt_position: tilt_position = self.service.value( CharacteristicsTypes.HORIZONTAL_TILT_CURRENT ) + if tilt_position is None: + return None # Recalculate to convert from arcdegree scale to percentage scale. if self.is_vertical_tilt: scale = 0.9 diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 671e9779d30..2157eb51212 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -3,6 +3,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -94,6 +95,24 @@ def create_window_covering_service_with_v_tilt_2(accessory): tilt_target.maxValue = 0 +def create_window_covering_service_with_none_tilt(accessory): + """Define a window-covering characteristics as per page 219 of HAP spec. + + This accessory uses None for the tilt value unexpectedly. + """ + service = create_window_covering_service(accessory) + + tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT) + tilt_current.value = None + tilt_current.minValue = -90 + tilt_current.maxValue = 0 + + tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET) + tilt_target.value = None + tilt_target.minValue = -90 + tilt_target.maxValue = 0 + + async def test_change_window_cover_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit alarm on and off again.""" helper = await setup_test_component(hass, create_window_covering_service) @@ -212,6 +231,21 @@ async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: assert state.attributes["current_tilt_position"] == 83 +async def test_read_window_cover_tilt_missing_tilt(hass: HomeAssistant) -> None: + """Test that missing tilt is handled.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_none_tilt + ) + + await helper.async_update( + ServicesTypes.WINDOW_COVERING, + {CharacteristicsTypes.OBSTRUCTION_DETECTED: True}, + ) + state = await helper.poll_and_get_state() + assert "current_tilt_position" not in state.attributes + assert state.state != STATE_UNAVAILABLE + + async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( From 56b38cd8427a2c131d61eddf35f559f9e7942d4c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 9 May 2024 16:31:36 +0200 Subject: [PATCH 061/164] Fix typo in xiaomi_ble translation strings (#117144) --- homeassistant/components/xiaomi_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 8ee8bac3fea..048c9bd92e2 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -83,7 +83,7 @@ "button_fan": "Button Fan \"{subtype}\"", "button_swing": "Button Swing \"{subtype}\"", "button_decrease_speed": "Button Decrease Speed \"{subtype}\"", - "button_increase_speed": "Button Inrease Speed \"{subtype}\"", + "button_increase_speed": "Button Increase Speed \"{subtype}\"", "button_stop": "Button Stop \"{subtype}\"", "button_light": "Button Light \"{subtype}\"", "button_wind_speed": "Button Wind Speed \"{subtype}\"", From f07c00a05b24f1808a7e30d1361fff2f11d167cd Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 10 May 2024 18:59:28 +0100 Subject: [PATCH 062/164] Bump pytrydan to 0.6.0 (#117162) --- homeassistant/components/v2c/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ce0e9d7b847..fb234d726e8 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/v2c", "iot_class": "local_polling", - "requirements": ["pytrydan==0.4.0"] + "requirements": ["pytrydan==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1ee861f25bc..e0cc726f3e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2337,7 +2337,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.10 # homeassistant.components.v2c -pytrydan==0.4.0 +pytrydan==0.6.0 # homeassistant.components.usb pyudev==0.24.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 189322bd545..6b9ce0504f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1816,7 +1816,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.10 # homeassistant.components.v2c -pytrydan==0.4.0 +pytrydan==0.6.0 # homeassistant.components.usb pyudev==0.24.1 From 2c8b3ac8bbf76f764214b760db82990e32c915ed Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 10 May 2024 13:33:18 +0200 Subject: [PATCH 063/164] Bump deebot-client to 7.2.0 (#117189) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index aad04d9ec87..e6bd59e3d12 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.9", "deebot-client==7.1.0"] + "requirements": ["py-sucks==0.9.9", "deebot-client==7.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e0cc726f3e0..f0acc214f78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -697,7 +697,7 @@ debugpy==1.8.1 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==7.1.0 +deebot-client==7.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b9ce0504f5..47f4f1baf51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -575,7 +575,7 @@ dbus-fast==2.21.1 debugpy==1.8.1 # homeassistant.components.ecovacs -deebot-client==7.1.0 +deebot-client==7.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From e2da28fbdb3c226f624dce7c18eedc62abcd8c87 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 May 2024 18:14:24 +0000 Subject: [PATCH 064/164] Bump version to 2024.5.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e9e1231712e..4bab6d0f127 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 8fb7839c628..5c24c020e82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.2" +version = "2024.5.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 52147e519622cf79b4b6f19851f937ad8a22af39 Mon Sep 17 00:00:00 2001 From: amura11 Date: Wed, 15 May 2024 07:01:55 -0600 Subject: [PATCH 065/164] Fix Fully Kiosk set config service (#112840) * Fixed a bug that prevented setting Fully Kiosk config values using a template * Added test to cover change * Fixed issue identified by Ruff * Update services.py --------- Co-authored-by: Erik Montnemery --- .../components/fully_kiosk/services.py | 23 +++++++++++-------- tests/components/fully_kiosk/test_services.py | 16 +++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index c1e0d89f7a1..b9369198940 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -69,18 +69,21 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def async_set_config(call: ServiceCall) -> None: """Set a Fully Kiosk Browser config value on the device.""" for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + # Fully API has different methods for setting string and bool values. # check if call.data[ATTR_VALUE] is a bool - if isinstance(call.data[ATTR_VALUE], bool) or call.data[ - ATTR_VALUE - ].lower() in ("true", "false"): - await coordinator.fully.setConfigurationBool( - call.data[ATTR_KEY], call.data[ATTR_VALUE] - ) + if isinstance(value, bool) or ( + isinstance(value, str) and value.lower() in ("true", "false") + ): + await coordinator.fully.setConfigurationBool(key, value) else: - await coordinator.fully.setConfigurationString( - call.data[ATTR_KEY], call.data[ATTR_VALUE] - ) + # Convert any int values to string + if isinstance(value, int): + value = str(value) + + await coordinator.fully.setConfigurationString(key, value) # Register all the above services service_mapping = [ @@ -111,7 +114,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: { vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Required(ATTR_KEY): cv.string, - vol.Required(ATTR_VALUE): vol.Any(str, bool), + vol.Required(ATTR_VALUE): vol.Any(str, bool, int), } ) ), diff --git a/tests/components/fully_kiosk/test_services.py b/tests/components/fully_kiosk/test_services.py index eaf00d74a91..ecc81d0f090 100644 --- a/tests/components/fully_kiosk/test_services.py +++ b/tests/components/fully_kiosk/test_services.py @@ -71,6 +71,22 @@ async def test_services( mock_fully_kiosk.setConfigurationString.assert_called_once_with(key, value) + key = "test_key" + value = 1234 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG, + { + ATTR_DEVICE_ID: [device_entry.id], + ATTR_KEY: key, + ATTR_VALUE: value, + }, + blocking=True, + ) + + mock_fully_kiosk.setConfigurationString.assert_called_with(key, str(value)) + key = "test_key" value = "true" await hass.services.async_call( From 4501658a169c9c68329e1873b1c8bac8b5f51460 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 15 May 2024 15:12:47 +0200 Subject: [PATCH 066/164] Mark Duotecno entities unavailable when tcp goes down (#114325) When the tcp connection to the duotecno smartbox goes down, mark all entities as unavailable. --- homeassistant/components/duotecno/entity.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index 86f61c8a73c..7661080f231 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -41,6 +41,11 @@ class DuotecnoEntity(Entity): """When a unit has an update.""" self.async_write_ha_state() + @property + def available(self) -> bool: + """Available state for the unit.""" + return self._unit.is_available() + _T = TypeVar("_T", bound="DuotecnoEntity") _P = ParamSpec("_P") From afb5e622cda6dae91e92949dea8737ad7254c673 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 9 May 2024 16:56:26 +0200 Subject: [PATCH 067/164] Catch auth exception in husqvarna automower (#115365) * Catch AuthException in Husqvarna Automower * don't use getattr * raise ConfigEntryAuthFailed --- .../husqvarna_automower/coordinator.py | 9 ++++++- .../husqvarna_automower/test_init.py | 24 +++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 8d9588db5b7..817789727ca 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -4,12 +4,17 @@ import asyncio from datetime import timedelta import logging -from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError +from aioautomower.exceptions import ( + ApiException, + AuthException, + HusqvarnaWSServerHandshakeError, +) from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -46,6 +51,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib return await self.api.get_status() except ApiException as err: raise UpdateFailed(err) from err + except AuthException as err: + raise ConfigEntryAuthFailed(err) from err @callback def callback(self, ws_data: dict[str, MowerAttributes]) -> None: diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index dbf1d429eee..387c90cec38 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -5,7 +5,11 @@ import http import time from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError +from aioautomower.exceptions import ( + ApiException, + AuthException, + HusqvarnaWSServerHandshakeError, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -75,19 +79,25 @@ async def test_expired_token_refresh_failure( assert mock_config_entry.state is expected_state +@pytest.mark.parametrize( + ("exception", "entry_state"), + [ + (ApiException, ConfigEntryState.SETUP_RETRY), + (AuthException, ConfigEntryState.SETUP_ERROR), + ], +) async def test_update_failed( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, + exception: Exception, + entry_state: ConfigEntryState, ) -> None: - """Test load and unload entry.""" - getattr(mock_automower_client, "get_status").side_effect = ApiException( - "Test error" - ) + """Test update failed.""" + mock_automower_client.get_status.side_effect = exception("Test error") await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] - - assert entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is entry_state async def test_websocket_not_available( From 652ee1b90dd286dc7c199343fca18230375bb4e1 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 6 May 2024 01:22:22 -0700 Subject: [PATCH 068/164] Avoid exceptions when Gemini responses are blocked (#116847) * Bump google-generativeai to v0.5.2 * Avoid exceptions when Gemini responses are blocked * pytest --snapshot-update * set error response * add test * ruff --- .../__init__.py | 13 ++++--- .../test_init.py | 36 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e956c288b53..96be366a658 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -182,11 +182,11 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): conversation_id = ulid.ulid_now() messages = [{}, {}] + intent_response = intent.IntentResponse(language=user_input.language) try: prompt = self._async_generate_prompt(raw_prompt) except TemplateError as err: _LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, f"Sorry, I had a problem with my template: {err}", @@ -210,7 +210,6 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): genai_types.StopCandidateException, ) as err: _LOGGER.error("Error sending message: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, f"Sorry, I had a problem talking to Google Generative AI: {err}", @@ -220,9 +219,15 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): ) _LOGGER.debug("Response: %s", chat_response.parts) + if not chat_response.parts: + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem talking to Google Generative AI. Likely blocked", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) self.history[conversation_id] = chat.history - - intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(chat_response.text) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 07254be9e3f..bdf796b8c44 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -95,29 +95,59 @@ async def test_default_prompt( suggested_area="Test Area 2", ) with patch("google.generativeai.GenerativeModel") as mock_model: - mock_model.return_value.start_chat.return_value = AsyncMock() + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + chat_response.parts = ["Hi there!"] + chat_response.text = "Hi there!" result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: - """Test that the default prompt works.""" + """Test that client errors are caught.""" with patch("google.generativeai.GenerativeModel") as mock_model: mock_chat = AsyncMock() mock_model.return_value.start_chat.return_value = mock_chat - mock_chat.send_message_async.side_effect = ClientError("") + mock_chat.send_message_async.side_effect = ClientError("some error") result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "Sorry, I had a problem talking to Google Generative AI: None some error" + ) + + +async def test_blocked_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test response was blocked.""" + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + chat_response.parts = [] + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "Sorry, I had a problem talking to Google Generative AI. Likely blocked" + ) async def test_template_error( From 9d25d228ab8a315e4fff301d7a3121c36d713e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Tue, 7 May 2024 19:55:03 +0200 Subject: [PATCH 069/164] Reduce update interval in Ondilo Ico (#116989) Ondilo: reduce update interval The API seems to have sticter rate-limiting and frequent requests fail with HTTP 400. Fixes #116593 --- homeassistant/components/ondilo_ico/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index d3e9b4a4e11..9b22cf334f3 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -24,7 +24,7 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): hass, logger=_LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=5), + update_interval=timedelta(minutes=20), ) self.api = api From a53b8cc0e2402cc9f6e4bca018825504006dae14 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 13 May 2024 23:00:51 +0200 Subject: [PATCH 070/164] Add reauth for missing token scope in Husqvarna Automower (#117098) * Add repair for wrong token scope to Husqvarna Automower * avoid new installations with missing scope * tweaks * just reauth * texts * Add link to correct account * Update homeassistant/components/husqvarna_automower/strings.json Co-authored-by: Martin Hjelmare * Update homeassistant/components/husqvarna_automower/strings.json Co-authored-by: Martin Hjelmare * Update homeassistant/components/husqvarna_automower/strings.json Co-authored-by: Martin Hjelmare * Add comment * directly assert mock_missing_scope_config_entry.state is loaded * assert that a flow is started * pass complete url to strings and simplify texts * shorten long line * address review * simplify tests * grammar * remove obsolete fixture * fix test * Update tests/components/husqvarna_automower/test_init.py Co-authored-by: Martin Hjelmare * test if reauth flow has started --------- Co-authored-by: Martin Hjelmare --- .../husqvarna_automower/__init__.py | 5 ++ .../husqvarna_automower/config_flow.py | 27 ++++++++++ .../husqvarna_automower/strings.json | 7 ++- .../husqvarna_automower/conftest.py | 12 +++-- .../husqvarna_automower/test_config_flow.py | 51 +++++++++++++++---- .../husqvarna_automower/test_init.py | 20 ++++++++ 6 files changed, 108 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index fe6f6978014..e4211e1078e 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -57,6 +57,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + if "amc:api" not in entry.data["token"]["scope"]: + # We raise ConfigEntryAuthFailed here because the websocket can't be used + # without the scope. So only polling would be possible. + raise ConfigEntryAuthFailed + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index b25a185c75f..c848f823b13 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -13,7 +13,9 @@ from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, NAME _LOGGER = logging.getLogger(__name__) + CONF_USER_ID = "user_id" +HUSQVARNA_DEV_PORTAL_URL = "https://developer.husqvarnagroup.cloud/applications" class HusqvarnaConfigFlowHandler( @@ -29,8 +31,14 @@ class HusqvarnaConfigFlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow.""" token = data[CONF_TOKEN] + if "amc:api" not in token["scope"] and not self.reauth_entry: + return self.async_abort(reason="missing_amc_scope") user_id = token[CONF_USER_ID] if self.reauth_entry: + if "amc:api" not in token["scope"]: + return self.async_update_reload_and_abort( + self.reauth_entry, data=data, reason="missing_amc_scope" + ) if self.reauth_entry.unique_id != user_id: return self.async_abort(reason="wrong_account") return self.async_update_reload_and_abort(self.reauth_entry, data=data) @@ -56,6 +64,9 @@ class HusqvarnaConfigFlowHandler( self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + if self.reauth_entry is not None: + if "amc:api" not in self.reauth_entry.data["token"]["scope"]: + return await self.async_step_missing_scope() return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -65,3 +76,19 @@ class HusqvarnaConfigFlowHandler( if user_input is None: return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + + async def async_step_missing_scope( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth for missing scope.""" + if user_input is None and self.reauth_entry is not None: + token_structured = structure_token( + self.reauth_entry.data["token"]["access_token"] + ) + return self.async_show_form( + step_id="missing_scope", + description_placeholders={ + "application_url": f"{HUSQVARNA_DEV_PORTAL_URL}/{token_structured.client_id}" + }, + ) + return await self.async_step_user() diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index b4c1c97cd68..ea9a76fc319 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -5,6 +5,10 @@ "title": "[%key:common::config_flow::title::reauth%]", "description": "The Husqvarna Automower integration needs to re-authenticate your account" }, + "missing_scope": { + "title": "Your account is missing some API connections", + "description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url})." + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } @@ -22,7 +26,8 @@ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "You can only reauthenticate this entry with the same Husqvarna account." + "wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.", + "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 5d7cb43698b..bf7cced2bca 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -22,7 +22,7 @@ from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture @pytest.fixture(name="jwt") -def load_jwt_fixture(): +def load_jwt_fixture() -> str: """Load Fixture data.""" return load_fixture("jwt", DOMAIN) @@ -33,8 +33,14 @@ def mock_expires_at() -> float: return time.time() + 3600 +@pytest.fixture(name="scope") +def mock_scope() -> str: + """Fixture to set correct scope for the token.""" + return "iam:read amc:api" + + @pytest.fixture -def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry: +def mock_config_entry(jwt: str, expires_at: int, scope: str) -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( version=1, @@ -44,7 +50,7 @@ def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry: "auth_implementation": DOMAIN, "token": { "access_token": jwt, - "scope": "iam:read amc:api", + "scope": scope, "expires_in": 86399, "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", "provider": "husqvarna", diff --git a/tests/components/husqvarna_automower/test_config_flow.py b/tests/components/husqvarna_automower/test_config_flow.py index 0a345eed627..bb97a88d44f 100644 --- a/tests/components/husqvarna_automower/test_config_flow.py +++ b/tests/components/husqvarna_automower/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, patch +import pytest + from homeassistant import config_entries from homeassistant.components.husqvarna_automower.const import ( DOMAIN, @@ -21,12 +23,21 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + ("new_scope", "amount"), + [ + ("iam:read amc:api", 1), + ("iam:read", 0), + ], +) async def test_full_flow( hass: HomeAssistant, hass_client_no_auth, aioclient_mock: AiohttpClientMocker, current_request_with_host, - jwt, + jwt: str, + new_scope: str, + amount: int, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -56,7 +67,7 @@ async def test_full_flow( OAUTH2_TOKEN, json={ "access_token": jwt, - "scope": "iam:read amc:api", + "scope": new_scope, "expires_in": 86399, "refresh_token": "mock-refresh-token", "provider": "husqvarna", @@ -72,8 +83,8 @@ async def test_full_flow( ) as mock_setup: await hass.config_entries.flow.async_configure(result["flow_id"]) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == amount + assert len(mock_setup.mock_calls) == amount async def test_config_non_unique_profile( @@ -129,6 +140,14 @@ async def test_config_non_unique_profile( assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("scope", "step_id", "reason", "new_scope"), + [ + ("iam:read amc:api", "reauth_confirm", "reauth_successful", "iam:read amc:api"), + ("iam:read", "missing_scope", "reauth_successful", "iam:read amc:api"), + ("iam:read", "missing_scope", "missing_amc_scope", "iam:read"), + ], +) async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -136,7 +155,10 @@ async def test_reauth( mock_config_entry: MockConfigEntry, current_request_with_host: None, mock_automower_client: AsyncMock, - jwt, + jwt: str, + step_id: str, + new_scope: str, + reason: str, ) -> None: """Test the reauthentication case updates the existing config entry.""" @@ -148,7 +170,7 @@ async def test_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 result = flows[0] - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == step_id result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) state = config_entry_oauth2_flow._encode_jwt( @@ -172,7 +194,7 @@ async def test_reauth( OAUTH2_TOKEN, json={ "access_token": "mock-updated-token", - "scope": "iam:read amc:api", + "scope": new_scope, "expires_in": 86399, "refresh_token": "mock-refresh-token", "provider": "husqvarna", @@ -191,7 +213,7 @@ async def test_reauth( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "reauth_successful" + assert result.get("reason") == reason assert mock_config_entry.unique_id == USER_ID assert "token" in mock_config_entry.data @@ -200,6 +222,12 @@ async def test_reauth( assert mock_config_entry.data["token"].get("refresh_token") == "mock-refresh-token" +@pytest.mark.parametrize( + ("user_id", "reason"), + [ + ("wrong_user_id", "wrong_account"), + ], +) async def test_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -208,6 +236,9 @@ async def test_reauth_wrong_account( current_request_with_host: None, mock_automower_client: AsyncMock, jwt, + user_id: str, + reason: str, + scope: str, ) -> None: """Test the reauthentication aborts, if user tries to reauthenticate with another account.""" @@ -247,7 +278,7 @@ async def test_reauth_wrong_account( "expires_in": 86399, "refresh_token": "mock-refresh-token", "provider": "husqvarna", - "user_id": "wrong-user-id", + "user_id": user_id, "token_type": "Bearer", "expires_at": 1697753347, }, @@ -262,7 +293,7 @@ async def test_reauth_wrong_account( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "wrong_account" + assert result.get("reason") == reason assert mock_config_entry.unique_id == USER_ID assert "token" in mock_config_entry.data diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 387c90cec38..84fe1b9e891 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -43,6 +43,26 @@ async def test_load_unload_entry( assert entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("scope"), + [ + ("iam:read"), + ], +) +async def test_load_missing_scope( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if the entry starts a reauth with the missing token scope.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "missing_scope" + + @pytest.mark.parametrize( ("expires_at", "status", "expected_state"), [ From 5941cf05e4f1053d323bc2bccd8c4e106455cbee Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Thu, 16 May 2024 21:45:03 -0400 Subject: [PATCH 071/164] Fix issue changing Insteon Hub configuration (#117204) Add Hub version to config schema --- homeassistant/components/insteon/schemas.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 837c6224014..4cf8d49d170 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -22,6 +22,7 @@ from .const import ( CONF_CAT, CONF_DIM_STEPS, CONF_HOUSECODE, + CONF_HUB_VERSION, CONF_SUBCAT, CONF_UNITCODE, HOUSECODES, @@ -143,6 +144,7 @@ def build_hub_schema( schema = { vol.Required(CONF_HOST, default=host): str, vol.Required(CONF_PORT, default=port): int, + vol.Required(CONF_HUB_VERSION, default=hub_version): int, } if hub_version == 2: schema[vol.Required(CONF_USERNAME, default=username)] = str From 17c6a49ff82743c72de62fc85ac8b3f9e7f670e3 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 10 May 2024 20:38:38 -0500 Subject: [PATCH 072/164] Bump SoCo to 0.30.4 (#117212) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index ec5ef90a0c1..d6c5eb298d8 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.3", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index f0acc214f78..d867cd826bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2572,7 +2572,7 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.3 +soco==0.30.4 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47f4f1baf51..640c4cfcfd1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.3 +soco==0.30.4 # homeassistant.components.solax solax==3.1.0 From 57cf91a8d4b2d189a3d6c69aa83ff4a139294384 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 11 May 2024 11:41:03 -0400 Subject: [PATCH 073/164] Fix zwave_js discovery logic for node device class (#117232) * Fix zwave_js discovery logic for node device class * simplify check --- .../components/zwave_js/discovery.py | 29 +- tests/components/zwave_js/conftest.py | 14 + .../light_device_class_is_null_state.json | 10611 ++++++++++++++++ tests/components/zwave_js/test_discovery.py | 12 + 4 files changed, 10649 insertions(+), 17 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/light_device_class_is_null_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 272f6e3ddc0..4e2b59109e8 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -41,7 +41,6 @@ from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_SETPOINT_PROPERTY, ) from zwave_js_server.exceptions import UnknownValueData -from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, @@ -1180,14 +1179,22 @@ def async_discover_single_value( continue # check device_class_generic - if value.node.device_class and not check_device_class( - value.node.device_class.generic, schema.device_class_generic + if schema.device_class_generic and ( + not value.node.device_class + or not any( + value.node.device_class.generic.label == val + for val in schema.device_class_generic + ) ): continue # check device_class_specific - if value.node.device_class and not check_device_class( - value.node.device_class.specific, schema.device_class_specific + if schema.device_class_specific and ( + not value.node.device_class + or not any( + value.node.device_class.specific.label == val + for val in schema.device_class_specific + ) ): continue @@ -1379,15 +1386,3 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: if schema.stateful is not None and value.metadata.stateful != schema.stateful: return False return True - - -@callback -def check_device_class( - device_class: DeviceClassItem, required_value: set[str] | None -) -> bool: - """Check if device class id or label matches.""" - if required_value is None: - return True - if any(device_class.label == val for val in required_value): - return True - return False diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index dbf7357d4a0..f6497492b8b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -675,6 +675,12 @@ def central_scene_node_state_fixture(): return json.loads(load_fixture("zwave_js/central_scene_node_state.json")) +@pytest.fixture(name="light_device_class_is_null_state", scope="package") +def light_device_class_is_null_state_fixture(): + """Load node with device class is None state fixture data.""" + return json.loads(load_fixture("zwave_js/light_device_class_is_null_state.json")) + + # model fixtures @@ -1325,3 +1331,11 @@ def central_scene_node_fixture(client, central_scene_node_state): node = Node(client, copy.deepcopy(central_scene_node_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="light_device_class_is_null") +def light_device_class_is_null_fixture(client, light_device_class_is_null_state): + """Mock a node when device class is null.""" + node = Node(client, copy.deepcopy(light_device_class_is_null_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/light_device_class_is_null_state.json b/tests/components/zwave_js/fixtures/light_device_class_is_null_state.json new file mode 100644 index 00000000000..e736c432062 --- /dev/null +++ b/tests/components/zwave_js/fixtures/light_device_class_is_null_state.json @@ -0,0 +1,10611 @@ +{ + "nodeId": 45, + "index": 0, + "installerIcon": 1536, + "userIcon": 1536, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 29, + "productId": 1, + "productType": 12801, + "firmwareVersion": "1.20", + "zwavePlusVersion": 1, + "name": "Bar Display Cases", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/Users/spike/zwavestore/.config-db/devices/0x001d/dz6hd.json", + "isEmbedded": true, + "manufacturer": "Leviton", + "manufacturerId": 29, + "label": "DZ6HD", + "description": "In-Wall 600W Dimmer", + "devices": [ + { + "productType": 12801, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Enter programming mode by holding down the top of the paddle for 7 seconds, the LED will blink Amber. Tap the top of the paddle one time. The LED will flash green. Upon successful addition to network, the LED will blink 3 times.", + "exclusion": "Enter programming mode by holding down the top of the paddle for 7 seconds, the LED will blink Amber. Tap the top of the paddle one time. The LED will flash green. Upon successful removal from network, the LED will blink 3 times.", + "reset": "Hold the top of the paddle down for 14 seconds. Upon successful reset, the LED with blink red/amber.", + "manual": "https://www.leviton.com/fr/docs/DI-000-DZ6HD-02A-W.pdf" + } + }, + "label": "DZ6HD", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": null, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x001d:0x3201:0x0001:1.20", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 31.5, + "lastSeen": "2024-05-10T21:42:42.472Z", + "lwr": { + "repeaters": [], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-05-10T21:42:42.472Z", + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 1, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 1, + "propertyName": "level", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (1)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 1, + "propertyName": "dimmingDuration", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (1)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 2, + "propertyName": "level", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (2)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 2, + "propertyName": "dimmingDuration", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (2)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 3, + "propertyName": "level", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (3)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 3, + "propertyName": "dimmingDuration", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (3)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 4, + "propertyName": "level", + "propertyKeyName": "4", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (4)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 4, + "propertyName": "dimmingDuration", + "propertyKeyName": "4", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (4)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 5, + "propertyName": "level", + "propertyKeyName": "5", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (5)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 5, + "propertyName": "dimmingDuration", + "propertyKeyName": "5", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (5)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 6, + "propertyName": "level", + "propertyKeyName": "6", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (6)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 6, + "propertyName": "dimmingDuration", + "propertyKeyName": "6", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (6)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 7, + "propertyName": "level", + "propertyKeyName": "7", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (7)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 7, + "propertyName": "dimmingDuration", + "propertyKeyName": "7", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (7)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 8, + "propertyName": "level", + "propertyKeyName": "8", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (8)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 8, + "propertyName": "dimmingDuration", + "propertyKeyName": "8", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (8)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 9, + "propertyName": "level", + "propertyKeyName": "9", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (9)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 9, + "propertyName": "dimmingDuration", + "propertyKeyName": "9", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (9)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 10, + "propertyName": "level", + "propertyKeyName": "10", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (10)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 10, + "propertyName": "dimmingDuration", + "propertyKeyName": "10", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (10)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 11, + "propertyName": "level", + "propertyKeyName": "11", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (11)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 11, + "propertyName": "dimmingDuration", + "propertyKeyName": "11", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (11)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 12, + "propertyName": "level", + "propertyKeyName": "12", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (12)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 12, + "propertyName": "dimmingDuration", + "propertyKeyName": "12", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (12)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 13, + "propertyName": "level", + "propertyKeyName": "13", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (13)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 13, + "propertyName": "dimmingDuration", + "propertyKeyName": "13", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (13)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 14, + "propertyName": "level", + "propertyKeyName": "14", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (14)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 14, + "propertyName": "dimmingDuration", + "propertyKeyName": "14", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (14)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 15, + "propertyName": "level", + "propertyKeyName": "15", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (15)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 15, + "propertyName": "dimmingDuration", + "propertyKeyName": "15", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (15)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 16, + "propertyName": "level", + "propertyKeyName": "16", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (16)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 16, + "propertyName": "dimmingDuration", + "propertyKeyName": "16", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (16)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 17, + "propertyName": "level", + "propertyKeyName": "17", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (17)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 17, + "propertyName": "dimmingDuration", + "propertyKeyName": "17", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (17)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 18, + "propertyName": "level", + "propertyKeyName": "18", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (18)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 18, + "propertyName": "dimmingDuration", + "propertyKeyName": "18", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (18)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 19, + "propertyName": "level", + "propertyKeyName": "19", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (19)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 19, + "propertyName": "dimmingDuration", + "propertyKeyName": "19", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (19)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 20, + "propertyName": "level", + "propertyKeyName": "20", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (20)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 20, + "propertyName": "dimmingDuration", + "propertyKeyName": "20", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (20)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 21, + "propertyName": "level", + "propertyKeyName": "21", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (21)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 21, + "propertyName": "dimmingDuration", + "propertyKeyName": "21", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (21)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 22, + "propertyName": "level", + "propertyKeyName": "22", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (22)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 22, + "propertyName": "dimmingDuration", + "propertyKeyName": "22", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (22)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 23, + "propertyName": "level", + "propertyKeyName": "23", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (23)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 23, + "propertyName": "dimmingDuration", + "propertyKeyName": "23", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (23)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 24, + "propertyName": "level", + "propertyKeyName": "24", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (24)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 24, + "propertyName": "dimmingDuration", + "propertyKeyName": "24", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (24)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 25, + "propertyName": "level", + "propertyKeyName": "25", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (25)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 25, + "propertyName": "dimmingDuration", + "propertyKeyName": "25", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (25)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 26, + "propertyName": "level", + "propertyKeyName": "26", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (26)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 26, + "propertyName": "dimmingDuration", + "propertyKeyName": "26", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (26)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 27, + "propertyName": "level", + "propertyKeyName": "27", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (27)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 27, + "propertyName": "dimmingDuration", + "propertyKeyName": "27", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (27)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 28, + "propertyName": "level", + "propertyKeyName": "28", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (28)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 28, + "propertyName": "dimmingDuration", + "propertyKeyName": "28", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (28)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 29, + "propertyName": "level", + "propertyKeyName": "29", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (29)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 29, + "propertyName": "dimmingDuration", + "propertyKeyName": "29", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (29)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 30, + "propertyName": "level", + "propertyKeyName": "30", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (30)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 30, + "propertyName": "dimmingDuration", + "propertyKeyName": "30", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (30)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 31, + "propertyName": "level", + "propertyKeyName": "31", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (31)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 31, + "propertyName": "dimmingDuration", + "propertyKeyName": "31", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (31)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 32, + "propertyName": "level", + "propertyKeyName": "32", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (32)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 32, + "propertyName": "dimmingDuration", + "propertyKeyName": "32", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (32)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 33, + "propertyName": "level", + "propertyKeyName": "33", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (33)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 33, + "propertyName": "dimmingDuration", + "propertyKeyName": "33", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (33)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 34, + "propertyName": "level", + "propertyKeyName": "34", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (34)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 34, + "propertyName": "dimmingDuration", + "propertyKeyName": "34", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (34)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 35, + "propertyName": "level", + "propertyKeyName": "35", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (35)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 35, + "propertyName": "dimmingDuration", + "propertyKeyName": "35", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (35)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 36, + "propertyName": "level", + "propertyKeyName": "36", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (36)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 36, + "propertyName": "dimmingDuration", + "propertyKeyName": "36", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (36)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 37, + "propertyName": "level", + "propertyKeyName": "37", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (37)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 37, + "propertyName": "dimmingDuration", + "propertyKeyName": "37", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (37)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 38, + "propertyName": "level", + "propertyKeyName": "38", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (38)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 38, + "propertyName": "dimmingDuration", + "propertyKeyName": "38", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (38)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 39, + "propertyName": "level", + "propertyKeyName": "39", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (39)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 39, + "propertyName": "dimmingDuration", + "propertyKeyName": "39", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (39)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 40, + "propertyName": "level", + "propertyKeyName": "40", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (40)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 40, + "propertyName": "dimmingDuration", + "propertyKeyName": "40", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (40)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 41, + "propertyName": "level", + "propertyKeyName": "41", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (41)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 41, + "propertyName": "dimmingDuration", + "propertyKeyName": "41", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (41)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 42, + "propertyName": "level", + "propertyKeyName": "42", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (42)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 42, + "propertyName": "dimmingDuration", + "propertyKeyName": "42", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (42)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 43, + "propertyName": "level", + "propertyKeyName": "43", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (43)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 43, + "propertyName": "dimmingDuration", + "propertyKeyName": "43", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (43)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 44, + "propertyName": "level", + "propertyKeyName": "44", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (44)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 44, + "propertyName": "dimmingDuration", + "propertyKeyName": "44", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (44)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 45, + "propertyName": "level", + "propertyKeyName": "45", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (45)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 45, + "propertyName": "dimmingDuration", + "propertyKeyName": "45", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (45)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 46, + "propertyName": "level", + "propertyKeyName": "46", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (46)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 46, + "propertyName": "dimmingDuration", + "propertyKeyName": "46", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (46)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 47, + "propertyName": "level", + "propertyKeyName": "47", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (47)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 47, + "propertyName": "dimmingDuration", + "propertyKeyName": "47", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (47)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 48, + "propertyName": "level", + "propertyKeyName": "48", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (48)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 48, + "propertyName": "dimmingDuration", + "propertyKeyName": "48", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (48)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 49, + "propertyName": "level", + "propertyKeyName": "49", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (49)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 49, + "propertyName": "dimmingDuration", + "propertyKeyName": "49", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (49)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 50, + "propertyName": "level", + "propertyKeyName": "50", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (50)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 50, + "propertyName": "dimmingDuration", + "propertyKeyName": "50", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (50)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 51, + "propertyName": "level", + "propertyKeyName": "51", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (51)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 51, + "propertyName": "dimmingDuration", + "propertyKeyName": "51", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (51)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 52, + "propertyName": "level", + "propertyKeyName": "52", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (52)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 52, + "propertyName": "dimmingDuration", + "propertyKeyName": "52", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (52)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 53, + "propertyName": "level", + "propertyKeyName": "53", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (53)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 53, + "propertyName": "dimmingDuration", + "propertyKeyName": "53", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (53)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 54, + "propertyName": "level", + "propertyKeyName": "54", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (54)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 54, + "propertyName": "dimmingDuration", + "propertyKeyName": "54", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (54)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 55, + "propertyName": "level", + "propertyKeyName": "55", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (55)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 55, + "propertyName": "dimmingDuration", + "propertyKeyName": "55", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (55)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 56, + "propertyName": "level", + "propertyKeyName": "56", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (56)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 56, + "propertyName": "dimmingDuration", + "propertyKeyName": "56", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (56)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 57, + "propertyName": "level", + "propertyKeyName": "57", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (57)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 57, + "propertyName": "dimmingDuration", + "propertyKeyName": "57", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (57)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 58, + "propertyName": "level", + "propertyKeyName": "58", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (58)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 58, + "propertyName": "dimmingDuration", + "propertyKeyName": "58", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (58)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 59, + "propertyName": "level", + "propertyKeyName": "59", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (59)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 59, + "propertyName": "dimmingDuration", + "propertyKeyName": "59", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (59)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 60, + "propertyName": "level", + "propertyKeyName": "60", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (60)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 60, + "propertyName": "dimmingDuration", + "propertyKeyName": "60", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (60)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 61, + "propertyName": "level", + "propertyKeyName": "61", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (61)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 61, + "propertyName": "dimmingDuration", + "propertyKeyName": "61", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (61)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 62, + "propertyName": "level", + "propertyKeyName": "62", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (62)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 62, + "propertyName": "dimmingDuration", + "propertyKeyName": "62", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (62)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 63, + "propertyName": "level", + "propertyKeyName": "63", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (63)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 63, + "propertyName": "dimmingDuration", + "propertyKeyName": "63", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (63)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 64, + "propertyName": "level", + "propertyKeyName": "64", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (64)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 64, + "propertyName": "dimmingDuration", + "propertyKeyName": "64", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (64)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 65, + "propertyName": "level", + "propertyKeyName": "65", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (65)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 65, + "propertyName": "dimmingDuration", + "propertyKeyName": "65", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (65)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 66, + "propertyName": "level", + "propertyKeyName": "66", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (66)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 66, + "propertyName": "dimmingDuration", + "propertyKeyName": "66", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (66)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 67, + "propertyName": "level", + "propertyKeyName": "67", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (67)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 67, + "propertyName": "dimmingDuration", + "propertyKeyName": "67", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (67)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 68, + "propertyName": "level", + "propertyKeyName": "68", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (68)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 68, + "propertyName": "dimmingDuration", + "propertyKeyName": "68", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (68)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 69, + "propertyName": "level", + "propertyKeyName": "69", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (69)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 69, + "propertyName": "dimmingDuration", + "propertyKeyName": "69", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (69)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 70, + "propertyName": "level", + "propertyKeyName": "70", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (70)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 70, + "propertyName": "dimmingDuration", + "propertyKeyName": "70", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (70)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 71, + "propertyName": "level", + "propertyKeyName": "71", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (71)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 71, + "propertyName": "dimmingDuration", + "propertyKeyName": "71", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (71)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 72, + "propertyName": "level", + "propertyKeyName": "72", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (72)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 72, + "propertyName": "dimmingDuration", + "propertyKeyName": "72", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (72)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 73, + "propertyName": "level", + "propertyKeyName": "73", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (73)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 73, + "propertyName": "dimmingDuration", + "propertyKeyName": "73", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (73)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 74, + "propertyName": "level", + "propertyKeyName": "74", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (74)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 74, + "propertyName": "dimmingDuration", + "propertyKeyName": "74", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (74)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 75, + "propertyName": "level", + "propertyKeyName": "75", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (75)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 75, + "propertyName": "dimmingDuration", + "propertyKeyName": "75", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (75)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 76, + "propertyName": "level", + "propertyKeyName": "76", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (76)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 76, + "propertyName": "dimmingDuration", + "propertyKeyName": "76", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (76)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 77, + "propertyName": "level", + "propertyKeyName": "77", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (77)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 77, + "propertyName": "dimmingDuration", + "propertyKeyName": "77", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (77)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 78, + "propertyName": "level", + "propertyKeyName": "78", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (78)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 78, + "propertyName": "dimmingDuration", + "propertyKeyName": "78", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (78)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 79, + "propertyName": "level", + "propertyKeyName": "79", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (79)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 79, + "propertyName": "dimmingDuration", + "propertyKeyName": "79", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (79)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 80, + "propertyName": "level", + "propertyKeyName": "80", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (80)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 80, + "propertyName": "dimmingDuration", + "propertyKeyName": "80", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (80)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 81, + "propertyName": "level", + "propertyKeyName": "81", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (81)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 81, + "propertyName": "dimmingDuration", + "propertyKeyName": "81", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (81)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 82, + "propertyName": "level", + "propertyKeyName": "82", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (82)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 82, + "propertyName": "dimmingDuration", + "propertyKeyName": "82", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (82)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 83, + "propertyName": "level", + "propertyKeyName": "83", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (83)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 83, + "propertyName": "dimmingDuration", + "propertyKeyName": "83", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (83)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 84, + "propertyName": "level", + "propertyKeyName": "84", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (84)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 84, + "propertyName": "dimmingDuration", + "propertyKeyName": "84", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (84)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 85, + "propertyName": "level", + "propertyKeyName": "85", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (85)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 85, + "propertyName": "dimmingDuration", + "propertyKeyName": "85", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (85)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 86, + "propertyName": "level", + "propertyKeyName": "86", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (86)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 86, + "propertyName": "dimmingDuration", + "propertyKeyName": "86", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (86)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 87, + "propertyName": "level", + "propertyKeyName": "87", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (87)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 87, + "propertyName": "dimmingDuration", + "propertyKeyName": "87", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (87)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 88, + "propertyName": "level", + "propertyKeyName": "88", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (88)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 88, + "propertyName": "dimmingDuration", + "propertyKeyName": "88", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (88)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 89, + "propertyName": "level", + "propertyKeyName": "89", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (89)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 89, + "propertyName": "dimmingDuration", + "propertyKeyName": "89", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (89)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 90, + "propertyName": "level", + "propertyKeyName": "90", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (90)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 90, + "propertyName": "dimmingDuration", + "propertyKeyName": "90", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (90)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 91, + "propertyName": "level", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (91)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 91, + "propertyName": "dimmingDuration", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (91)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 92, + "propertyName": "level", + "propertyKeyName": "92", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (92)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 92, + "propertyName": "dimmingDuration", + "propertyKeyName": "92", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (92)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 93, + "propertyName": "level", + "propertyKeyName": "93", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (93)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 93, + "propertyName": "dimmingDuration", + "propertyKeyName": "93", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (93)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 94, + "propertyName": "level", + "propertyKeyName": "94", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (94)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 94, + "propertyName": "dimmingDuration", + "propertyKeyName": "94", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (94)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 95, + "propertyName": "level", + "propertyKeyName": "95", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (95)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 95, + "propertyName": "dimmingDuration", + "propertyKeyName": "95", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (95)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 96, + "propertyName": "level", + "propertyKeyName": "96", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (96)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 96, + "propertyName": "dimmingDuration", + "propertyKeyName": "96", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (96)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 97, + "propertyName": "level", + "propertyKeyName": "97", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (97)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 97, + "propertyName": "dimmingDuration", + "propertyKeyName": "97", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (97)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 98, + "propertyName": "level", + "propertyKeyName": "98", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (98)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 98, + "propertyName": "dimmingDuration", + "propertyKeyName": "98", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (98)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 99, + "propertyName": "level", + "propertyKeyName": "99", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (99)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 99, + "propertyName": "dimmingDuration", + "propertyKeyName": "99", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (99)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 100, + "propertyName": "level", + "propertyKeyName": "100", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (100)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 100, + "propertyName": "dimmingDuration", + "propertyKeyName": "100", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (100)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 101, + "propertyName": "level", + "propertyKeyName": "101", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (101)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 101, + "propertyName": "dimmingDuration", + "propertyKeyName": "101", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (101)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 102, + "propertyName": "level", + "propertyKeyName": "102", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (102)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 102, + "propertyName": "dimmingDuration", + "propertyKeyName": "102", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (102)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 103, + "propertyName": "level", + "propertyKeyName": "103", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (103)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 103, + "propertyName": "dimmingDuration", + "propertyKeyName": "103", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (103)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 104, + "propertyName": "level", + "propertyKeyName": "104", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (104)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 104, + "propertyName": "dimmingDuration", + "propertyKeyName": "104", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (104)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 105, + "propertyName": "level", + "propertyKeyName": "105", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (105)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 105, + "propertyName": "dimmingDuration", + "propertyKeyName": "105", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (105)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 106, + "propertyName": "level", + "propertyKeyName": "106", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (106)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 106, + "propertyName": "dimmingDuration", + "propertyKeyName": "106", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (106)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 107, + "propertyName": "level", + "propertyKeyName": "107", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (107)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 107, + "propertyName": "dimmingDuration", + "propertyKeyName": "107", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (107)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 108, + "propertyName": "level", + "propertyKeyName": "108", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (108)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 108, + "propertyName": "dimmingDuration", + "propertyKeyName": "108", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (108)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 109, + "propertyName": "level", + "propertyKeyName": "109", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (109)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 109, + "propertyName": "dimmingDuration", + "propertyKeyName": "109", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (109)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 110, + "propertyName": "level", + "propertyKeyName": "110", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (110)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 110, + "propertyName": "dimmingDuration", + "propertyKeyName": "110", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (110)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 111, + "propertyName": "level", + "propertyKeyName": "111", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (111)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 111, + "propertyName": "dimmingDuration", + "propertyKeyName": "111", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (111)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 112, + "propertyName": "level", + "propertyKeyName": "112", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (112)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 112, + "propertyName": "dimmingDuration", + "propertyKeyName": "112", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (112)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 113, + "propertyName": "level", + "propertyKeyName": "113", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (113)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 113, + "propertyName": "dimmingDuration", + "propertyKeyName": "113", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (113)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 114, + "propertyName": "level", + "propertyKeyName": "114", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (114)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 114, + "propertyName": "dimmingDuration", + "propertyKeyName": "114", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (114)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 115, + "propertyName": "level", + "propertyKeyName": "115", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (115)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 115, + "propertyName": "dimmingDuration", + "propertyKeyName": "115", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (115)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 116, + "propertyName": "level", + "propertyKeyName": "116", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (116)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 116, + "propertyName": "dimmingDuration", + "propertyKeyName": "116", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (116)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 117, + "propertyName": "level", + "propertyKeyName": "117", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (117)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 117, + "propertyName": "dimmingDuration", + "propertyKeyName": "117", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (117)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 118, + "propertyName": "level", + "propertyKeyName": "118", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (118)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 118, + "propertyName": "dimmingDuration", + "propertyKeyName": "118", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (118)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 119, + "propertyName": "level", + "propertyKeyName": "119", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (119)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 119, + "propertyName": "dimmingDuration", + "propertyKeyName": "119", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (119)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 120, + "propertyName": "level", + "propertyKeyName": "120", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (120)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 120, + "propertyName": "dimmingDuration", + "propertyKeyName": "120", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (120)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 121, + "propertyName": "level", + "propertyKeyName": "121", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (121)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 121, + "propertyName": "dimmingDuration", + "propertyKeyName": "121", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (121)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 122, + "propertyName": "level", + "propertyKeyName": "122", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (122)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 122, + "propertyName": "dimmingDuration", + "propertyKeyName": "122", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (122)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 123, + "propertyName": "level", + "propertyKeyName": "123", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (123)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 123, + "propertyName": "dimmingDuration", + "propertyKeyName": "123", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (123)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 124, + "propertyName": "level", + "propertyKeyName": "124", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (124)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 124, + "propertyName": "dimmingDuration", + "propertyKeyName": "124", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (124)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 125, + "propertyName": "level", + "propertyKeyName": "125", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (125)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 125, + "propertyName": "dimmingDuration", + "propertyKeyName": "125", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (125)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 126, + "propertyName": "level", + "propertyKeyName": "126", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (126)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 126, + "propertyName": "dimmingDuration", + "propertyKeyName": "126", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (126)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 127, + "propertyName": "level", + "propertyKeyName": "127", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (127)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 127, + "propertyName": "dimmingDuration", + "propertyKeyName": "127", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (127)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 128, + "propertyName": "level", + "propertyKeyName": "128", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (128)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 128, + "propertyName": "dimmingDuration", + "propertyKeyName": "128", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (128)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 129, + "propertyName": "level", + "propertyKeyName": "129", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (129)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 129, + "propertyName": "dimmingDuration", + "propertyKeyName": "129", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (129)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 130, + "propertyName": "level", + "propertyKeyName": "130", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (130)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 130, + "propertyName": "dimmingDuration", + "propertyKeyName": "130", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (130)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 131, + "propertyName": "level", + "propertyKeyName": "131", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (131)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 131, + "propertyName": "dimmingDuration", + "propertyKeyName": "131", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (131)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 132, + "propertyName": "level", + "propertyKeyName": "132", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (132)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 132, + "propertyName": "dimmingDuration", + "propertyKeyName": "132", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (132)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 133, + "propertyName": "level", + "propertyKeyName": "133", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (133)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 133, + "propertyName": "dimmingDuration", + "propertyKeyName": "133", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (133)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 134, + "propertyName": "level", + "propertyKeyName": "134", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (134)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 134, + "propertyName": "dimmingDuration", + "propertyKeyName": "134", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (134)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 135, + "propertyName": "level", + "propertyKeyName": "135", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (135)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 135, + "propertyName": "dimmingDuration", + "propertyKeyName": "135", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (135)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 136, + "propertyName": "level", + "propertyKeyName": "136", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (136)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 136, + "propertyName": "dimmingDuration", + "propertyKeyName": "136", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (136)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 137, + "propertyName": "level", + "propertyKeyName": "137", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (137)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 137, + "propertyName": "dimmingDuration", + "propertyKeyName": "137", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (137)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 138, + "propertyName": "level", + "propertyKeyName": "138", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (138)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 138, + "propertyName": "dimmingDuration", + "propertyKeyName": "138", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (138)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 139, + "propertyName": "level", + "propertyKeyName": "139", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (139)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 139, + "propertyName": "dimmingDuration", + "propertyKeyName": "139", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (139)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 140, + "propertyName": "level", + "propertyKeyName": "140", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (140)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 140, + "propertyName": "dimmingDuration", + "propertyKeyName": "140", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (140)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 141, + "propertyName": "level", + "propertyKeyName": "141", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (141)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 141, + "propertyName": "dimmingDuration", + "propertyKeyName": "141", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (141)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 142, + "propertyName": "level", + "propertyKeyName": "142", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (142)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 142, + "propertyName": "dimmingDuration", + "propertyKeyName": "142", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (142)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 143, + "propertyName": "level", + "propertyKeyName": "143", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (143)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 143, + "propertyName": "dimmingDuration", + "propertyKeyName": "143", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (143)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 144, + "propertyName": "level", + "propertyKeyName": "144", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (144)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 144, + "propertyName": "dimmingDuration", + "propertyKeyName": "144", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (144)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 145, + "propertyName": "level", + "propertyKeyName": "145", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (145)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 145, + "propertyName": "dimmingDuration", + "propertyKeyName": "145", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (145)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 146, + "propertyName": "level", + "propertyKeyName": "146", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (146)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 146, + "propertyName": "dimmingDuration", + "propertyKeyName": "146", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (146)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 147, + "propertyName": "level", + "propertyKeyName": "147", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (147)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 147, + "propertyName": "dimmingDuration", + "propertyKeyName": "147", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (147)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 148, + "propertyName": "level", + "propertyKeyName": "148", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (148)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 148, + "propertyName": "dimmingDuration", + "propertyKeyName": "148", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (148)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 149, + "propertyName": "level", + "propertyKeyName": "149", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (149)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 149, + "propertyName": "dimmingDuration", + "propertyKeyName": "149", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (149)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 150, + "propertyName": "level", + "propertyKeyName": "150", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (150)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 150, + "propertyName": "dimmingDuration", + "propertyKeyName": "150", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (150)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 151, + "propertyName": "level", + "propertyKeyName": "151", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (151)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 151, + "propertyName": "dimmingDuration", + "propertyKeyName": "151", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (151)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 152, + "propertyName": "level", + "propertyKeyName": "152", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (152)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 152, + "propertyName": "dimmingDuration", + "propertyKeyName": "152", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (152)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 153, + "propertyName": "level", + "propertyKeyName": "153", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (153)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 153, + "propertyName": "dimmingDuration", + "propertyKeyName": "153", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (153)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 154, + "propertyName": "level", + "propertyKeyName": "154", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (154)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 154, + "propertyName": "dimmingDuration", + "propertyKeyName": "154", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (154)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 155, + "propertyName": "level", + "propertyKeyName": "155", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (155)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 155, + "propertyName": "dimmingDuration", + "propertyKeyName": "155", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (155)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 156, + "propertyName": "level", + "propertyKeyName": "156", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (156)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 156, + "propertyName": "dimmingDuration", + "propertyKeyName": "156", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (156)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 157, + "propertyName": "level", + "propertyKeyName": "157", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (157)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 157, + "propertyName": "dimmingDuration", + "propertyKeyName": "157", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (157)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 158, + "propertyName": "level", + "propertyKeyName": "158", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (158)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 158, + "propertyName": "dimmingDuration", + "propertyKeyName": "158", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (158)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 159, + "propertyName": "level", + "propertyKeyName": "159", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (159)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 159, + "propertyName": "dimmingDuration", + "propertyKeyName": "159", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (159)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 160, + "propertyName": "level", + "propertyKeyName": "160", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (160)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 160, + "propertyName": "dimmingDuration", + "propertyKeyName": "160", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (160)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 161, + "propertyName": "level", + "propertyKeyName": "161", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (161)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 161, + "propertyName": "dimmingDuration", + "propertyKeyName": "161", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (161)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 162, + "propertyName": "level", + "propertyKeyName": "162", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (162)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 162, + "propertyName": "dimmingDuration", + "propertyKeyName": "162", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (162)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 163, + "propertyName": "level", + "propertyKeyName": "163", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (163)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 163, + "propertyName": "dimmingDuration", + "propertyKeyName": "163", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (163)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 164, + "propertyName": "level", + "propertyKeyName": "164", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (164)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 164, + "propertyName": "dimmingDuration", + "propertyKeyName": "164", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (164)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 165, + "propertyName": "level", + "propertyKeyName": "165", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (165)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 165, + "propertyName": "dimmingDuration", + "propertyKeyName": "165", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (165)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 166, + "propertyName": "level", + "propertyKeyName": "166", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (166)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 166, + "propertyName": "dimmingDuration", + "propertyKeyName": "166", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (166)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 167, + "propertyName": "level", + "propertyKeyName": "167", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (167)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 167, + "propertyName": "dimmingDuration", + "propertyKeyName": "167", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (167)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 168, + "propertyName": "level", + "propertyKeyName": "168", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (168)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 168, + "propertyName": "dimmingDuration", + "propertyKeyName": "168", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (168)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 169, + "propertyName": "level", + "propertyKeyName": "169", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (169)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 169, + "propertyName": "dimmingDuration", + "propertyKeyName": "169", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (169)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 170, + "propertyName": "level", + "propertyKeyName": "170", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (170)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 170, + "propertyName": "dimmingDuration", + "propertyKeyName": "170", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (170)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 171, + "propertyName": "level", + "propertyKeyName": "171", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (171)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 171, + "propertyName": "dimmingDuration", + "propertyKeyName": "171", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (171)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 172, + "propertyName": "level", + "propertyKeyName": "172", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (172)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 172, + "propertyName": "dimmingDuration", + "propertyKeyName": "172", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (172)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 173, + "propertyName": "level", + "propertyKeyName": "173", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (173)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 173, + "propertyName": "dimmingDuration", + "propertyKeyName": "173", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (173)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 174, + "propertyName": "level", + "propertyKeyName": "174", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (174)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 174, + "propertyName": "dimmingDuration", + "propertyKeyName": "174", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (174)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 175, + "propertyName": "level", + "propertyKeyName": "175", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (175)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 175, + "propertyName": "dimmingDuration", + "propertyKeyName": "175", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (175)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 176, + "propertyName": "level", + "propertyKeyName": "176", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (176)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 176, + "propertyName": "dimmingDuration", + "propertyKeyName": "176", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (176)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 177, + "propertyName": "level", + "propertyKeyName": "177", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (177)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 177, + "propertyName": "dimmingDuration", + "propertyKeyName": "177", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (177)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 178, + "propertyName": "level", + "propertyKeyName": "178", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (178)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 178, + "propertyName": "dimmingDuration", + "propertyKeyName": "178", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (178)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 179, + "propertyName": "level", + "propertyKeyName": "179", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (179)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 179, + "propertyName": "dimmingDuration", + "propertyKeyName": "179", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (179)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 180, + "propertyName": "level", + "propertyKeyName": "180", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (180)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 180, + "propertyName": "dimmingDuration", + "propertyKeyName": "180", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (180)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 181, + "propertyName": "level", + "propertyKeyName": "181", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (181)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 181, + "propertyName": "dimmingDuration", + "propertyKeyName": "181", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (181)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 182, + "propertyName": "level", + "propertyKeyName": "182", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (182)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 182, + "propertyName": "dimmingDuration", + "propertyKeyName": "182", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (182)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 183, + "propertyName": "level", + "propertyKeyName": "183", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (183)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 183, + "propertyName": "dimmingDuration", + "propertyKeyName": "183", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (183)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 184, + "propertyName": "level", + "propertyKeyName": "184", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (184)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 184, + "propertyName": "dimmingDuration", + "propertyKeyName": "184", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (184)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 185, + "propertyName": "level", + "propertyKeyName": "185", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (185)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 185, + "propertyName": "dimmingDuration", + "propertyKeyName": "185", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (185)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 186, + "propertyName": "level", + "propertyKeyName": "186", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (186)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 186, + "propertyName": "dimmingDuration", + "propertyKeyName": "186", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (186)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 187, + "propertyName": "level", + "propertyKeyName": "187", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (187)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 187, + "propertyName": "dimmingDuration", + "propertyKeyName": "187", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (187)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 188, + "propertyName": "level", + "propertyKeyName": "188", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (188)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 188, + "propertyName": "dimmingDuration", + "propertyKeyName": "188", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (188)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 189, + "propertyName": "level", + "propertyKeyName": "189", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (189)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 189, + "propertyName": "dimmingDuration", + "propertyKeyName": "189", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (189)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 190, + "propertyName": "level", + "propertyKeyName": "190", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (190)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 190, + "propertyName": "dimmingDuration", + "propertyKeyName": "190", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (190)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 191, + "propertyName": "level", + "propertyKeyName": "191", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (191)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 191, + "propertyName": "dimmingDuration", + "propertyKeyName": "191", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (191)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 192, + "propertyName": "level", + "propertyKeyName": "192", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (192)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 192, + "propertyName": "dimmingDuration", + "propertyKeyName": "192", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (192)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 193, + "propertyName": "level", + "propertyKeyName": "193", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (193)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 193, + "propertyName": "dimmingDuration", + "propertyKeyName": "193", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (193)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 194, + "propertyName": "level", + "propertyKeyName": "194", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (194)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 194, + "propertyName": "dimmingDuration", + "propertyKeyName": "194", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (194)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 195, + "propertyName": "level", + "propertyKeyName": "195", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (195)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 195, + "propertyName": "dimmingDuration", + "propertyKeyName": "195", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (195)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 196, + "propertyName": "level", + "propertyKeyName": "196", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (196)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 196, + "propertyName": "dimmingDuration", + "propertyKeyName": "196", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (196)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 197, + "propertyName": "level", + "propertyKeyName": "197", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (197)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 197, + "propertyName": "dimmingDuration", + "propertyKeyName": "197", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (197)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 198, + "propertyName": "level", + "propertyKeyName": "198", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (198)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 198, + "propertyName": "dimmingDuration", + "propertyKeyName": "198", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (198)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 199, + "propertyName": "level", + "propertyKeyName": "199", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (199)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 199, + "propertyName": "dimmingDuration", + "propertyKeyName": "199", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (199)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 200, + "propertyName": "level", + "propertyKeyName": "200", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (200)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 200, + "propertyName": "dimmingDuration", + "propertyKeyName": "200", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (200)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 201, + "propertyName": "level", + "propertyKeyName": "201", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (201)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 201, + "propertyName": "dimmingDuration", + "propertyKeyName": "201", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (201)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 202, + "propertyName": "level", + "propertyKeyName": "202", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (202)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 202, + "propertyName": "dimmingDuration", + "propertyKeyName": "202", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (202)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 203, + "propertyName": "level", + "propertyKeyName": "203", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (203)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 203, + "propertyName": "dimmingDuration", + "propertyKeyName": "203", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (203)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 204, + "propertyName": "level", + "propertyKeyName": "204", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (204)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 204, + "propertyName": "dimmingDuration", + "propertyKeyName": "204", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (204)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 205, + "propertyName": "level", + "propertyKeyName": "205", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (205)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 205, + "propertyName": "dimmingDuration", + "propertyKeyName": "205", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (205)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 206, + "propertyName": "level", + "propertyKeyName": "206", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (206)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 206, + "propertyName": "dimmingDuration", + "propertyKeyName": "206", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (206)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 207, + "propertyName": "level", + "propertyKeyName": "207", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (207)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 207, + "propertyName": "dimmingDuration", + "propertyKeyName": "207", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (207)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 208, + "propertyName": "level", + "propertyKeyName": "208", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (208)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 208, + "propertyName": "dimmingDuration", + "propertyKeyName": "208", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (208)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 209, + "propertyName": "level", + "propertyKeyName": "209", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (209)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 209, + "propertyName": "dimmingDuration", + "propertyKeyName": "209", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (209)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 210, + "propertyName": "level", + "propertyKeyName": "210", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (210)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 210, + "propertyName": "dimmingDuration", + "propertyKeyName": "210", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (210)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 211, + "propertyName": "level", + "propertyKeyName": "211", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (211)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 211, + "propertyName": "dimmingDuration", + "propertyKeyName": "211", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (211)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 212, + "propertyName": "level", + "propertyKeyName": "212", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (212)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 212, + "propertyName": "dimmingDuration", + "propertyKeyName": "212", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (212)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 213, + "propertyName": "level", + "propertyKeyName": "213", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (213)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 213, + "propertyName": "dimmingDuration", + "propertyKeyName": "213", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (213)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 214, + "propertyName": "level", + "propertyKeyName": "214", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (214)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 214, + "propertyName": "dimmingDuration", + "propertyKeyName": "214", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (214)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 215, + "propertyName": "level", + "propertyKeyName": "215", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (215)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 215, + "propertyName": "dimmingDuration", + "propertyKeyName": "215", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (215)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 216, + "propertyName": "level", + "propertyKeyName": "216", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (216)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 216, + "propertyName": "dimmingDuration", + "propertyKeyName": "216", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (216)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 217, + "propertyName": "level", + "propertyKeyName": "217", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (217)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 217, + "propertyName": "dimmingDuration", + "propertyKeyName": "217", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (217)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 218, + "propertyName": "level", + "propertyKeyName": "218", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (218)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 218, + "propertyName": "dimmingDuration", + "propertyKeyName": "218", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (218)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 219, + "propertyName": "level", + "propertyKeyName": "219", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (219)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 219, + "propertyName": "dimmingDuration", + "propertyKeyName": "219", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (219)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 220, + "propertyName": "level", + "propertyKeyName": "220", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (220)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 220, + "propertyName": "dimmingDuration", + "propertyKeyName": "220", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (220)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 221, + "propertyName": "level", + "propertyKeyName": "221", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (221)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 221, + "propertyName": "dimmingDuration", + "propertyKeyName": "221", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (221)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 222, + "propertyName": "level", + "propertyKeyName": "222", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (222)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 222, + "propertyName": "dimmingDuration", + "propertyKeyName": "222", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (222)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 223, + "propertyName": "level", + "propertyKeyName": "223", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (223)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 223, + "propertyName": "dimmingDuration", + "propertyKeyName": "223", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (223)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 224, + "propertyName": "level", + "propertyKeyName": "224", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (224)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 224, + "propertyName": "dimmingDuration", + "propertyKeyName": "224", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (224)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 225, + "propertyName": "level", + "propertyKeyName": "225", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (225)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 225, + "propertyName": "dimmingDuration", + "propertyKeyName": "225", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (225)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 226, + "propertyName": "level", + "propertyKeyName": "226", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (226)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 226, + "propertyName": "dimmingDuration", + "propertyKeyName": "226", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (226)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 227, + "propertyName": "level", + "propertyKeyName": "227", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (227)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 227, + "propertyName": "dimmingDuration", + "propertyKeyName": "227", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (227)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 228, + "propertyName": "level", + "propertyKeyName": "228", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (228)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 228, + "propertyName": "dimmingDuration", + "propertyKeyName": "228", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (228)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 229, + "propertyName": "level", + "propertyKeyName": "229", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (229)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 229, + "propertyName": "dimmingDuration", + "propertyKeyName": "229", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (229)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 230, + "propertyName": "level", + "propertyKeyName": "230", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (230)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 230, + "propertyName": "dimmingDuration", + "propertyKeyName": "230", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (230)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 231, + "propertyName": "level", + "propertyKeyName": "231", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (231)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 231, + "propertyName": "dimmingDuration", + "propertyKeyName": "231", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (231)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 232, + "propertyName": "level", + "propertyKeyName": "232", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (232)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 232, + "propertyName": "dimmingDuration", + "propertyKeyName": "232", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (232)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 233, + "propertyName": "level", + "propertyKeyName": "233", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (233)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 233, + "propertyName": "dimmingDuration", + "propertyKeyName": "233", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (233)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 234, + "propertyName": "level", + "propertyKeyName": "234", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (234)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 234, + "propertyName": "dimmingDuration", + "propertyKeyName": "234", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (234)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 235, + "propertyName": "level", + "propertyKeyName": "235", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (235)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 235, + "propertyName": "dimmingDuration", + "propertyKeyName": "235", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (235)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 236, + "propertyName": "level", + "propertyKeyName": "236", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (236)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 236, + "propertyName": "dimmingDuration", + "propertyKeyName": "236", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (236)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 237, + "propertyName": "level", + "propertyKeyName": "237", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (237)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 237, + "propertyName": "dimmingDuration", + "propertyKeyName": "237", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (237)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 238, + "propertyName": "level", + "propertyKeyName": "238", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (238)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 238, + "propertyName": "dimmingDuration", + "propertyKeyName": "238", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (238)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 239, + "propertyName": "level", + "propertyKeyName": "239", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (239)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 239, + "propertyName": "dimmingDuration", + "propertyKeyName": "239", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (239)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 240, + "propertyName": "level", + "propertyKeyName": "240", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (240)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 240, + "propertyName": "dimmingDuration", + "propertyKeyName": "240", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (240)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 241, + "propertyName": "level", + "propertyKeyName": "241", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (241)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 241, + "propertyName": "dimmingDuration", + "propertyKeyName": "241", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (241)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 242, + "propertyName": "level", + "propertyKeyName": "242", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (242)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 242, + "propertyName": "dimmingDuration", + "propertyKeyName": "242", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (242)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 243, + "propertyName": "level", + "propertyKeyName": "243", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (243)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 243, + "propertyName": "dimmingDuration", + "propertyKeyName": "243", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (243)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 244, + "propertyName": "level", + "propertyKeyName": "244", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (244)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 244, + "propertyName": "dimmingDuration", + "propertyKeyName": "244", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (244)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 245, + "propertyName": "level", + "propertyKeyName": "245", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (245)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 245, + "propertyName": "dimmingDuration", + "propertyKeyName": "245", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (245)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 246, + "propertyName": "level", + "propertyKeyName": "246", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (246)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 246, + "propertyName": "dimmingDuration", + "propertyKeyName": "246", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (246)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 247, + "propertyName": "level", + "propertyKeyName": "247", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (247)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 247, + "propertyName": "dimmingDuration", + "propertyKeyName": "247", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (247)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 248, + "propertyName": "level", + "propertyKeyName": "248", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (248)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 248, + "propertyName": "dimmingDuration", + "propertyKeyName": "248", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (248)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 249, + "propertyName": "level", + "propertyKeyName": "249", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (249)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 249, + "propertyName": "dimmingDuration", + "propertyKeyName": "249", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (249)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 250, + "propertyName": "level", + "propertyKeyName": "250", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (250)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 250, + "propertyName": "dimmingDuration", + "propertyKeyName": "250", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (250)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 251, + "propertyName": "level", + "propertyKeyName": "251", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (251)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 251, + "propertyName": "dimmingDuration", + "propertyKeyName": "251", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (251)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 252, + "propertyName": "level", + "propertyKeyName": "252", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (252)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 252, + "propertyName": "dimmingDuration", + "propertyKeyName": "252", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (252)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 253, + "propertyName": "level", + "propertyKeyName": "253", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (253)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 253, + "propertyName": "dimmingDuration", + "propertyKeyName": "253", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (253)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 254, + "propertyName": "level", + "propertyKeyName": "254", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (254)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 254, + "propertyName": "dimmingDuration", + "propertyKeyName": "254", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (254)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 255, + "propertyName": "level", + "propertyKeyName": "255", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (255)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 255, + "propertyName": "dimmingDuration", + "propertyKeyName": "255", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (255)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Fade On Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Values 1-127 = seconds; 128-253 = minutes (minus 127)", + "label": "Fade On Time", + "default": 2, + "min": 0, + "max": 253, + "states": { + "0": "Instant on" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Fade Off Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Values 1-127 = seconds; 128-253 = minutes (minus 127)", + "label": "Fade Off Time", + "default": 2, + "min": 0, + "max": 253, + "states": { + "0": "Instant off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Minimum Dim Level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Minimum Dim Level", + "default": 10, + "min": 1, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Maximum Dim Level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Maximum Dim Level", + "default": 100, + "min": 0, + "max": 100, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Initial Dim Level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Initial Dim Level", + "default": 0, + "min": 0, + "max": 100, + "states": { + "0": "Last dim level" + }, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "LED Dim Level Indicator Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "How long the level indicators should stay illuminated after the dimming level is changed", + "label": "LED Dim Level Indicator Timeout", + "default": 3, + "min": 0, + "max": 255, + "states": { + "0": "Always Off", + "255": "Always On" + }, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Locator LED Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Locator LED Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "LED always off", + "254": "LED on when switch is on", + "255": "LED on when switch is off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Load Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Load Type", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Incandescent", + "1": "LED", + "2": "CFL" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 29 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 12801 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "4.33" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.20"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 255 + } + ], + "endpoints": [ + { + "nodeId": 45, + "index": 0, + "installerIcon": 1536, + "userIcon": 1536, + "deviceClass": null, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 44, + "name": "Scene Actuator Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index fe231707629..9c926f9b19b 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -305,3 +305,15 @@ async def test_indicator_test( "propertyKey": "Switch", } assert args["value"] is False + + +async def test_light_device_class_is_null( + hass: HomeAssistant, client, light_device_class_is_null, integration +) -> None: + """Test that a Multilevel Switch CC value with a null device class is discovered as a light. + + Tied to #117121. + """ + node = light_device_class_is_null + assert node.device_class is None + assert hass.states.get("light.bar_display_cases") From dba4785c9b9bf36e665bc7cb398d54db3ffd1c76 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 12 May 2024 13:13:41 +0200 Subject: [PATCH 074/164] Increase MQTT broker socket buffer size (#117267) * Increase MQTT broker socket buffer size * Revert unrelated change * Try to increase buffer size * Set INITIAL_SUBSCRIBE_COOLDOWN back to 0.5 sec * Sinplify and add test * comments * comments --------- Co-authored-by: J. Nick Koston --- homeassistant/components/mqtt/client.py | 37 ++++++++++++++++++++++++- tests/components/mqtt/test_init.py | 28 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 589113d3a9e..8245363fd85 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -83,8 +83,18 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails +PREFERRED_BUFFER_SIZE = 2097152 # Set receive buffer size to 2MB + DISCOVERY_COOLDOWN = 5 -INITIAL_SUBSCRIBE_COOLDOWN = 3.0 +# The initial subscribe cooldown controls how long to wait to group +# subscriptions together. This is to avoid making too many subscribe +# requests in a short period of time. If the number is too low, the +# system will be flooded with subscribe requests. If the number is too +# high, we risk being flooded with responses to the subscribe requests +# which can exceed the receive buffer size of the socket. To mitigate +# this, we increase the receive buffer size of the socket as well. +INITIAL_SUBSCRIBE_COOLDOWN = 0.5 SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 @@ -429,6 +439,7 @@ class MQTT: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._async_ha_stop), ) ) + self._socket_buffersize: int | None = None @callback def _async_ha_started(self, _hass: HomeAssistant) -> None: @@ -529,6 +540,29 @@ class MQTT: self.hass, self._misc_loop(), name="mqtt misc loop" ) + def _increase_socket_buffer_size(self, sock: SocketType) -> None: + """Increase the socket buffer size.""" + new_buffer_size = PREFERRED_BUFFER_SIZE + while True: + try: + # Some operating systems do not allow us to set the preferred + # buffer size. In that case we try some other size options. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) + except OSError as err: + if new_buffer_size <= MIN_BUFFER_SIZE: + _LOGGER.warning( + "Unable to increase the socket buffer size to %s; " + "The connection may be unstable if the MQTT broker " + "sends data at volume or a large amount of subscriptions " + "need to be processed: %s", + new_buffer_size, + err, + ) + return + new_buffer_size //= 2 + else: + return + def _on_socket_open( self, client: mqtt.Client, userdata: Any, sock: SocketType ) -> None: @@ -545,6 +579,7 @@ class MQTT: fileno = sock.fileno() _LOGGER.debug("%s: connection opened %s", self.config_entry.title, fileno) if fileno > -1: + self._increase_socket_buffer_size(sock) self.loop.add_reader(sock, partial(self._async_reader_callback, client)) self._async_start_misc_loop() diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ec7968ae46b..448d41c59cc 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -4382,6 +4382,34 @@ async def test_server_sock_connect_and_disconnect( assert len(calls) == 0 +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_server_sock_buffer_size( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling the socket buffer size fails.""" + mqtt_mock = await mqtt_mock_entry() + await hass.async_block_till_done() + assert mqtt_mock.connected is True + + mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_SUCCESS + + client, server = socket.socketpair( + family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0 + ) + client.setblocking(False) + server.setblocking(False) + with patch.object(client, "setsockopt", side_effect=OSError("foo")): + mqtt_client_mock.on_socket_open(mqtt_client_mock, None, client) + mqtt_client_mock.on_socket_register_write(mqtt_client_mock, None, client) + await hass.async_block_till_done() + assert "Unable to increase the socket buffer size" in caplog.text + + @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) From bca20646bba4825d46eda40b38c120925e454916 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 12 May 2024 21:36:21 +0200 Subject: [PATCH 075/164] Fix Aurora naming (#117314) --- homeassistant/components/aurora/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 3aa917862fb..e0dd1de3b15 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -15,6 +15,7 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): """Implementation of the base Aurora Entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, From 642a6b44ebe314a658794eb5d46b3fb89bbb137f Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 12 May 2024 19:19:20 -0700 Subject: [PATCH 076/164] Call Google Assistant SDK service using async_add_executor_job (#117325) --- homeassistant/components/google_assistant_sdk/__init__.py | 4 +++- homeassistant/components/google_assistant_sdk/helpers.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 7d8653b509d..52950a82b93 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -169,7 +169,9 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): self.language = user_input.language self.assistant = TextAssistant(credentials, self.language) - resp = self.assistant.assist(user_input.text) + resp = await self.hass.async_add_executor_job( + self.assistant.assist, user_input.text + ) text_response = resp[0] or "" intent_response = intent.IntentResponse(language=user_input.language) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index ccd0fe765ac..b6b13f92fcf 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -79,7 +79,7 @@ async def async_send_text_commands( ) as assistant: command_response_list = [] for command in commands: - resp = assistant.assist(command) + resp = await hass.async_add_executor_job(assistant.assist, command) text_response = resp[0] _LOGGER.debug("command: %s\nresponse: %s", command, text_response) audio_response = resp[2] From c90818e10cd262e342faf13511748e50beeab0c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 May 2024 11:18:52 +0900 Subject: [PATCH 077/164] Fix squeezebox blocking startup (#117331) fixes #117079 --- homeassistant/components/squeezebox/media_player.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a3a404fe1ae..e822fe817b9 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -92,7 +92,7 @@ SQUEEZEBOX_MODE = { } -async def start_server_discovery(hass): +async def start_server_discovery(hass: HomeAssistant) -> None: """Start a server discovery task.""" def _discovered_server(server): @@ -110,8 +110,9 @@ async def start_server_discovery(hass): hass.data.setdefault(DOMAIN, {}) if DISCOVERY_TASK not in hass.data[DOMAIN]: _LOGGER.debug("Adding server discovery task for squeezebox") - hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_task( - async_discover(_discovered_server) + hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task( + async_discover(_discovered_server), + name="squeezebox server discovery", ) From f48f8eefe7b650bceb071c4675395eef6b710baf Mon Sep 17 00:00:00 2001 From: Jiaqi Wu Date: Mon, 13 May 2024 17:05:12 -0700 Subject: [PATCH 078/164] Fix Lutron Serena Tilt Only Wood Blinds set tilt function (#117374) --- homeassistant/components/lutron_caseta/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index aa5c2f4e0b9..04fbb9e54c1 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -96,7 +96,7 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaDeviceUpdatableEntity, CoverEntity): async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the blind to a specific tilt.""" - self._smartbridge.set_tilt(self.device_id, kwargs[ATTR_TILT_POSITION]) + await self._smartbridge.set_tilt(self.device_id, kwargs[ATTR_TILT_POSITION]) PYLUTRON_TYPE_TO_CLASSES = { From e7ff552de6db56d0eb70c17766d25b373b021686 Mon Sep 17 00:00:00 2001 From: mk-81 <63057155+mk-81@users.noreply.github.com> Date: Tue, 14 May 2024 21:02:17 +0200 Subject: [PATCH 079/164] Fix Kodi on/off status (#117436) * Fix Kodi Issue 104603 Fixes issue, that Kodi media player is displayed as online even when offline. The issue occurrs when using HTTP(S) only (no web Socket) integration after kodi was found online once. Issue: In async_update the connection exceptions from self._kodi.get_players are not catched and therefore self._players (and the like) are not reset. The call of self._connection.connected returns always true for HTTP(S) connections. Solution: Catch Exceptions from self._kodi.get_players und reset state in case of HTTP(S) only connection. Otherwise keep current behaviour. * Fix Kodi Issue 104603 / code style adjustments as requested --- homeassistant/components/kodi/media_player.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 74140ca873c..27b2d3e0199 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -480,7 +480,13 @@ class KodiEntity(MediaPlayerEntity): self._reset_state() return - self._players = await self._kodi.get_players() + try: + self._players = await self._kodi.get_players() + except (TransportError, ProtocolError): + if not self._connection.can_subscribe: + self._reset_state() + return + raise if self._kodi_is_off: self._reset_state() From 819e9860a8cd96c10de54a599b7c32a1010e0c0d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 14 May 2024 19:17:50 +0200 Subject: [PATCH 080/164] Update wled to 0.17.1 (#117444) --- homeassistant/components/wled/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index b6e14963b9e..fd15d8ef171 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.17.0"], + "requirements": ["wled==0.17.1"], "zeroconf": ["_wled._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d867cd826bc..2d5c6fd4769 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2866,7 +2866,7 @@ wiffi==1.1.2 wirelesstagpy==0.8.1 # homeassistant.components.wled -wled==0.17.0 +wled==0.17.1 # homeassistant.components.wolflink wolf-comm==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 640c4cfcfd1..da0cf834fa3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2222,7 +2222,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wled -wled==0.17.0 +wled==0.17.1 # homeassistant.components.wolflink wolf-comm==0.0.7 From 970ad8c07c2f5a723b00404f69fbd6469e1cedc0 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 14 May 2024 19:22:13 +0200 Subject: [PATCH 081/164] Bump pyduotecno to 2024.5.0 (#117446) --- homeassistant/components/duotecno/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 0c8eab8f0a0..e74c12227db 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2024.3.2"] + "requirements": ["pyDuotecno==2024.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d5c6fd4769..15cfc0e1394 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1649,7 +1649,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.3.2 +pyDuotecno==2024.5.0 # homeassistant.components.electrasmart pyElectra==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da0cf834fa3..c69f0514cf7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1305,7 +1305,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.3.2 +pyDuotecno==2024.5.0 # homeassistant.components.electrasmart pyElectra==1.2.0 From b86513c3a4306cb8f477ca1f752613392c04e5c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 May 2024 19:08:24 +0900 Subject: [PATCH 082/164] Fix non-thread-safe state write in tellduslive (#117487) --- homeassistant/components/tellduslive/const.py | 1 - homeassistant/components/tellduslive/cover.py | 6 +++--- homeassistant/components/tellduslive/entry.py | 18 ++++-------------- homeassistant/components/tellduslive/light.py | 2 +- homeassistant/components/tellduslive/switch.py | 4 ++-- 5 files changed, 10 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tellduslive/const.py b/homeassistant/components/tellduslive/const.py index 3a24f6b033a..eee36879ba9 100644 --- a/homeassistant/components/tellduslive/const.py +++ b/homeassistant/components/tellduslive/const.py @@ -24,7 +24,6 @@ SCAN_INTERVAL = timedelta(minutes=1) ATTR_LAST_UPDATED = "time_last_updated" -SIGNAL_UPDATE_ENTITY = "tellduslive_update" TELLDUS_DISCOVERY_NEW = "telldus_new_{}_{}" CLOUD_NAME = "Cloud API" diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index 57c6ae9e7eb..de962041333 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -46,14 +46,14 @@ class TelldusLiveCover(TelldusLiveEntity, CoverEntity): def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self.device.down() - self._update_callback() + self.schedule_update_ha_state() def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.device.up() - self._update_callback() + self.schedule_update_ha_state() def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self.device.stop() - self._update_callback() + self.schedule_update_ha_state() diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py index 77a04fabd06..a71fcb685c0 100644 --- a/homeassistant/components/tellduslive/entry.py +++ b/homeassistant/components/tellduslive/entry.py @@ -11,7 +11,6 @@ from homeassistant.const import ( ATTR_MODEL, ATTR_VIA_DEVICE, ) -from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -33,25 +32,16 @@ class TelldusLiveEntity(Entity): """Initialize the entity.""" self._id = device_id self._client = client - self._async_unsub_dispatcher_connect = None async def async_added_to_hass(self): """Call when entity is added to hass.""" _LOGGER.debug("Created device %s", self) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self.async_write_ha_state + ) ) - async def async_will_remove_from_hass(self): - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() - - @callback - def _update_callback(self): - """Return the property of the device might have changed.""" - self.async_write_ha_state() - @property def device_id(self): """Return the id of the device.""" diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 63af8a32527..101ccb0dab0 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -50,7 +50,7 @@ class TelldusLiveLight(TelldusLiveEntity, LightEntity): def changed(self): """Define a property of the device that might have changed.""" self._last_brightness = self.brightness - self._update_callback() + self.schedule_update_ha_state() @property def brightness(self): diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index c26a8dcf951..cd28a170442 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -45,9 +45,9 @@ class TelldusLiveSwitch(TelldusLiveEntity, SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self.device.turn_on() - self._update_callback() + self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.device.turn_off() - self._update_callback() + self.schedule_update_ha_state() From 615ae780ca1925d6f89cd6f130ce6cb8c6fbdea1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 15 May 2024 12:04:12 +0200 Subject: [PATCH 083/164] Reolink fix not unregistering webhook during ReAuth (#117490) --- homeassistant/components/reolink/__init__.py | 1 + tests/components/reolink/test_init.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 3196dbf3ad7..22b616f9f43 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -85,6 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await host.update_states() except CredentialsInvalidError as err: + await host.stop() raise ConfigEntryAuthFailed(err) from err except ReolinkError as err: raise UpdateFailed(str(err)) from err diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 4ec02244c91..261f572bf2e 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from reolink_aio.exceptions import ReolinkError +from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.components.reolink import FIRMWARE_UPDATE_INTERVAL, const from homeassistant.config import async_process_ha_core_config @@ -50,6 +50,11 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") AsyncMock(side_effect=ReolinkError("Test error")), ConfigEntryState.SETUP_RETRY, ), + ( + "get_states", + AsyncMock(side_effect=CredentialsInvalidError("Test error")), + ConfigEntryState.SETUP_ERROR, + ), ( "supported", Mock(return_value=False), From b1746faa47a6701e67536f3a0a982bce6e3a4541 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 May 2024 13:39:07 +0200 Subject: [PATCH 084/164] Fix API creation for passwordless pi_hole (#117494) --- homeassistant/components/pi_hole/__init__.py | 2 +- tests/components/pi_hole/test_init.py | 35 ++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index f892114b26c..922590a5cde 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: use_tls = entry.data[CONF_SSL] verify_tls = entry.data[CONF_VERIFY_SSL] location = entry.data[CONF_LOCATION] - api_key = entry.data.get(CONF_API_KEY) + api_key = entry.data.get(CONF_API_KEY, "") # remove obsolet CONF_STATISTICS_ONLY from entry.data if CONF_STATISTICS_ONLY in entry.data: diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index a58a46680bb..b8d66286c64 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -1,7 +1,7 @@ """Test pi_hole component.""" import logging -from unittest.mock import AsyncMock +from unittest.mock import ANY, AsyncMock from hole.exceptions import HoleError import pytest @@ -12,12 +12,20 @@ from homeassistant.components.pi_hole.const import ( SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_LOCATION, + CONF_NAME, + CONF_SSL, +) from homeassistant.core import HomeAssistant from . import ( + API_KEY, CONFIG_DATA, CONFIG_DATA_DEFAULTS, + CONFIG_ENTRY_WITHOUT_API_KEY, SWITCH_ENTITY_ID, _create_mocked_hole, _patch_init_hole, @@ -26,6 +34,29 @@ from . import ( from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("config_entry_data", "expected_api_token"), + [(CONFIG_DATA_DEFAULTS, API_KEY), (CONFIG_ENTRY_WITHOUT_API_KEY, "")], +) +async def test_setup_api( + hass: HomeAssistant, config_entry_data: dict, expected_api_token: str +) -> None: + """Tests the API object is created with the expected parameters.""" + mocked_hole = _create_mocked_hole() + config_entry_data = {**config_entry_data, CONF_STATISTICS_ONLY: True} + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config_entry_data) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole) as patched_init_hole: + assert await hass.config_entries.async_setup(entry.entry_id) + patched_init_hole.assert_called_once_with( + config_entry_data[CONF_HOST], + ANY, + api_token=expected_api_token, + location=config_entry_data[CONF_LOCATION], + tls=config_entry_data[CONF_SSL], + ) + + async def test_setup_with_defaults(hass: HomeAssistant) -> None: """Tests component setup with default config.""" mocked_hole = _create_mocked_hole() From 4548ff619c8992b7a9050e360b797baf97998ff0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 15 May 2024 16:19:02 +0200 Subject: [PATCH 085/164] Bump reolink-aio to 0.8.10 (#117501) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 81d11e2fd0a..1cec4c90890 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.8.9"] + "requirements": ["reolink-aio==0.8.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15cfc0e1394..675b01a31b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2439,7 +2439,7 @@ renault-api==0.2.2 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.9 +reolink-aio==0.8.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c69f0514cf7..c313ef952a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1897,7 +1897,7 @@ renault-api==0.2.2 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.9 +reolink-aio==0.8.10 # homeassistant.components.rflink rflink==0.0.66 From ab9ed0eba4fa2e7d9134a6d639ed50a947b258b7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 16 May 2024 13:43:03 +0200 Subject: [PATCH 086/164] Handle uncaught exceptions in Analytics insights (#117558) --- .../analytics_insights/config_flow.py | 3 ++ .../analytics_insights/strings.json | 3 +- .../analytics_insights/test_config_flow.py | 28 ++++++++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index cef5ac2e9e5..909290b1035 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -82,6 +82,9 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + return self.async_abort(reason="unknown") options = [ SelectOptionDict( diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 00c9cfa4404..3b770f189a4 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -13,7 +13,8 @@ } }, "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "no_integration_selected": "You must select at least one integration to track" diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py index 77264eb2439..6bfd0e798ce 100644 --- a/tests/components/analytics_insights/test_config_flow.py +++ b/tests/components/analytics_insights/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock import pytest from python_homeassistant_analytics import HomeassistantAnalyticsConnectionError -from homeassistant import config_entries from homeassistant.components.analytics_insights.const import ( CONF_TRACKED_CUSTOM_INTEGRATIONS, CONF_TRACKED_INTEGRATIONS, DOMAIN, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -61,7 +61,7 @@ async def test_form( ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -96,7 +96,7 @@ async def test_submitting_empty_form( ) -> None: """Test we can't submit an empty form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -128,20 +128,28 @@ async def test_submitting_empty_form( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (HomeassistantAnalyticsConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) async def test_form_cannot_connect( - hass: HomeAssistant, mock_analytics_client: AsyncMock + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + exception: Exception, + reason: str, ) -> None: """Test we handle cannot connect error.""" - mock_analytics_client.get_integrations.side_effect = ( - HomeassistantAnalyticsConnectionError - ) + mock_analytics_client.get_integrations.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == reason async def test_form_already_configured( @@ -159,7 +167,7 @@ async def test_form_already_configured( entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" From 5cd101d2b18239a2ea5d2c15409bfc551cfec405 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 16 May 2024 16:42:40 +0200 Subject: [PATCH 087/164] Fix poolsense naming (#117567) --- homeassistant/components/poolsense/entity.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/poolsense/entity.py b/homeassistant/components/poolsense/entity.py index eaf2c4ab540..88abe67670a 100644 --- a/homeassistant/components/poolsense/entity.py +++ b/homeassistant/components/poolsense/entity.py @@ -1,9 +1,10 @@ """Base entity for poolsense integration.""" +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION +from .const import ATTRIBUTION, DOMAIN from .coordinator import PoolSenseDataUpdateCoordinator @@ -11,6 +12,7 @@ class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]): """Implements a common class elements representing the PoolSense component.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -21,5 +23,8 @@ class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]): """Initialize poolsense sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"PoolSense {description.name}" self._attr_unique_id = f"{email}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, email)}, + model="PoolSense", + ) From f043b2db49feafe588a89767af680b809b22bd14 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 17 May 2024 08:44:09 +0200 Subject: [PATCH 088/164] Improve syncing light states to deCONZ groups (#117588) --- homeassistant/components/deconz/light.py | 34 ++++++++++++++++++------ tests/components/deconz/test_light.py | 2 +- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index fc5388d2b33..91a8bdf6110 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -2,13 +2,13 @@ from __future__ import annotations -from typing import Any, TypedDict, TypeVar +from typing import Any, TypedDict, TypeVar, cast from pydeconz.interfaces.groups import GroupHandler from pydeconz.interfaces.lights import LightHandler from pydeconz.models import ResourceType from pydeconz.models.event import EventType -from pydeconz.models.group import Group +from pydeconz.models.group import Group, TypedGroupAction from pydeconz.models.light.light import Light, LightAlert, LightColorMode, LightEffect from homeassistant.components.light import ( @@ -105,6 +105,23 @@ class SetStateAttributes(TypedDict, total=False): xy: tuple[float, float] +def update_color_state( + group: Group, lights: list[Light], override: bool = False +) -> None: + """Sync group color state with light.""" + data = { + attribute: light_attribute + for light in lights + for attribute in ("bri", "ct", "hue", "sat", "xy", "colormode", "effect") + if (light_attribute := light.raw["state"].get(attribute)) is not None + } + + if override: + group.raw["action"] = cast(TypedGroupAction, data) + else: + group.update(cast(dict[str, dict[str, Any]], {"action": data})) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -148,11 +165,12 @@ async def async_setup_entry( if (group := hub.api.groups[group_id]) and not group.lights: return - first = True - for light_id in group.lights: - if (light := hub.api.lights.lights.get(light_id)) and light.reachable: - group.update_color_state(light, update_all_attributes=first) - first = False + lights = [ + light + for light_id in group.lights + if (light := hub.api.lights.lights.get(light_id)) and light.reachable + ] + update_color_state(group, lights, True) async_add_entities([DeconzGroup(group, hub)]) @@ -326,7 +344,7 @@ class DeconzLight(DeconzBaseLight[Light]): if self._device.reachable and "attr" not in self._device.changed_keys: for group in self.hub.api.groups.values(): if self._device.resource_id in group.lights: - group.update_color_state(self._device) + update_color_state(group, [self._device]) class DeconzGroup(DeconzBaseLight[Group]): diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 5144f222484..d964361df57 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1522,4 +1522,4 @@ async def test_verify_group_color_mode_fallback( ) group_state = hass.states.get("light.opbergruimte") assert group_state.state == STATE_ON - assert group_state.attributes[ATTR_COLOR_MODE] is ColorMode.UNKNOWN + assert group_state.attributes[ATTR_COLOR_MODE] is ColorMode.BRIGHTNESS From 8896d134e93cc73ffbb094e2a8c28f5dd8d38678 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 May 2024 13:45:47 +0200 Subject: [PATCH 089/164] Bump version to 2024.5.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4bab6d0f127..278050b69e1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 5c24c020e82..1805545235f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.3" +version = "2024.5.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5c8f7fe52ae74e143afde48cc74a89cb29640a47 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 May 2024 14:13:10 +0200 Subject: [PATCH 090/164] Fix rc pylint warning for Home Assistant Analytics (#117635) --- homeassistant/components/analytics_insights/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index 909290b1035..64d1580223e 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -82,7 +82,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 + except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected error") return self.async_abort(reason="unknown") From 9dc66404e78e4460ebae18a0664bc5adad0b8df5 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 24 May 2024 04:42:45 -0400 Subject: [PATCH 091/164] Fix Sonos album artwork performance (#116391) --- .../components/sonos/media_browser.py | 14 +- tests/components/sonos/conftest.py | 43 +++++- .../sonos/fixtures/music_library_albums.json | 23 +++ .../fixtures/music_library_categories.json | 44 ++++++ .../sonos/fixtures/music_library_tracks.json | 14 ++ .../sonos/snapshots/test_media_browser.ambr | 133 ++++++++++++++++++ tests/components/sonos/test_media_browser.py | 82 +++++++++++ 7 files changed, 344 insertions(+), 9 deletions(-) create mode 100644 tests/components/sonos/fixtures/music_library_albums.json create mode 100644 tests/components/sonos/fixtures/music_library_categories.json create mode 100644 tests/components/sonos/fixtures/music_library_tracks.json create mode 100644 tests/components/sonos/snapshots/test_media_browser.ambr diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index eeadd7db232..008c539581b 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -53,14 +53,16 @@ def get_thumbnail_url_full( media_content_type: str, media_content_id: str, media_image_id: str | None = None, + item: MusicServiceItem | None = None, ) -> str | None: """Get thumbnail URL.""" if is_internal: - item = get_media( - media.library, - media_content_id, - media_content_type, - ) + if not item: + item = get_media( + media.library, + media_content_id, + media_content_type, + ) return urllib.parse.unquote(getattr(item, "album_art_uri", "")) return urllib.parse.unquote( @@ -255,7 +257,7 @@ def item_payload(item: DidlObject, get_thumbnail_url=None) -> BrowseMedia: content_id = get_content_id(item) thumbnail = None if getattr(item, "album_art_uri", None): - thumbnail = get_thumbnail_url(media_class, content_id) + thumbnail = get_thumbnail_url(media_class, content_id, item=item) return BrowseMedia( title=item.title, diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 15f371f272c..a7062b24e88 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -316,12 +316,35 @@ def sonos_favorites_fixture() -> SearchResult: class MockMusicServiceItem: """Mocks a Soco MusicServiceItem.""" - def __init__(self, title: str, item_id: str, parent_id: str, item_class: str): + def __init__( + self, + title: str, + item_id: str, + parent_id: str, + item_class: str, + album_art_uri: None | str = None, + ): """Initialize the mock item.""" self.title = title self.item_id = item_id self.item_class = item_class self.parent_id = parent_id + self.album_art_uri: None | str = album_art_uri + + +def list_from_json_fixture(file_name: str) -> list[MockMusicServiceItem]: + """Create a list of music service items from a json fixture file.""" + item_list = load_json_value_fixture(file_name, "sonos") + return [ + MockMusicServiceItem( + item.get("title"), + item.get("item_id"), + item.get("parent_id"), + item.get("item_class"), + item.get("album_art_uri"), + ) + for item in item_list + ] def mock_browse_by_idstring( @@ -398,6 +421,10 @@ def mock_browse_by_idstring( "object.container.album.musicAlbum", ), ] + if search_type == "tracks": + return list_from_json_fixture("music_library_tracks.json") + if search_type == "albums" and idstring == "A:ALBUM": + return list_from_json_fixture("music_library_albums.json") return [] @@ -416,13 +443,23 @@ def mock_get_music_library_information( ] +@pytest.fixture(name="music_library_browse_categories") +def music_library_browse_categories() -> list[MockMusicServiceItem]: + """Create fixture for top-level music library categories.""" + return list_from_json_fixture("music_library_categories.json") + + @pytest.fixture(name="music_library") -def music_library_fixture(sonos_favorites: SearchResult) -> Mock: +def music_library_fixture( + sonos_favorites: SearchResult, + music_library_browse_categories: list[MockMusicServiceItem], +) -> Mock: """Create music_library fixture.""" music_library = MagicMock() music_library.get_sonos_favorites.return_value = sonos_favorites - music_library.browse_by_idstring = mock_browse_by_idstring + music_library.browse_by_idstring = Mock(side_effect=mock_browse_by_idstring) music_library.get_music_library_information = mock_get_music_library_information + music_library.browse = Mock(return_value=music_library_browse_categories) return music_library diff --git a/tests/components/sonos/fixtures/music_library_albums.json b/tests/components/sonos/fixtures/music_library_albums.json new file mode 100644 index 00000000000..4941abe8ba7 --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_albums.json @@ -0,0 +1,23 @@ +[ + { + "title": "A Hard Day's Night", + "item_id": "A:ALBUM/A%20Hard%20Day's%20Night", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fA%2520Hard%2520Day's%2520Night%2f01%2520A%2520Hard%2520Day's%2520Night%25201.m4a&v=53" + }, + { + "title": "Abbey Road", + "item_id": "A:ALBUM/Abbey%20Road", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fAbbeyA%2520Road%2f01%2520Come%2520Together.m4a&v=53" + }, + { + "title": "Between Good And Evil", + "item_id": "A:ALBUM/Between%20Good%20And%20Evil", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fSantana%2fA%2520Between%2520Good%2520And%2520Evil%2f02%2520A%2520Persuasion.m4a&v=53" + } +] diff --git a/tests/components/sonos/fixtures/music_library_categories.json b/tests/components/sonos/fixtures/music_library_categories.json new file mode 100644 index 00000000000..b6d6d3bf2dd --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_categories.json @@ -0,0 +1,44 @@ +[ + { + "title": "Contributing Artists", + "item_id": "A:ARTIST", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Artists", + "item_id": "A:ALBUMARTIST", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Albums", + "item_id": "A:ALBUM", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Genres", + "item_id": "A:GENRE", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Composers", + "item_id": "A:COMPOSER", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Tracks", + "item_id": "A:TRACKS", + "parent_id": "A:", + "item_class": "object.container.playlistContainer" + }, + { + "title": "Playlists", + "item_id": "A:PLAYLISTS", + "parent_id": "A:", + "item_class": "object.container" + } +] diff --git a/tests/components/sonos/fixtures/music_library_tracks.json b/tests/components/sonos/fixtures/music_library_tracks.json new file mode 100644 index 00000000000..1f1fcdbc21c --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_tracks.json @@ -0,0 +1,14 @@ +[ + { + "title": "A Hard Day's Night", + "item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%20Night/A%20Hard%20Day%2fs%20Night.mp3", + "parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night", + "item_class": "object.container.album.musicTrack" + }, + { + "title": "I Should Have Known Better", + "item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3", + "parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night", + "item_class": "object.container.album.musicTrack" + } +] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr new file mode 100644 index 00000000000..b4388b148e5 --- /dev/null +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_browse_media_library + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'contributing_artist', + 'media_content_id': 'A:ARTIST', + 'media_content_type': 'contributing_artist', + 'thumbnail': None, + 'title': 'Contributing Artists', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'artist', + 'media_content_id': 'A:ALBUMARTIST', + 'media_content_type': 'artist', + 'thumbnail': None, + 'title': 'Artists', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM', + 'media_content_type': 'album', + 'thumbnail': None, + 'title': 'Albums', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'genre', + 'media_content_id': 'A:GENRE', + 'media_content_type': 'genre', + 'thumbnail': None, + 'title': 'Genres', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'composer', + 'media_content_id': 'A:COMPOSER', + 'media_content_type': 'composer', + 'thumbnail': None, + 'title': 'Composers', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'A:TRACKS', + 'media_content_type': 'track', + 'thumbnail': None, + 'title': 'Tracks', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'playlist', + 'media_content_id': 'A:PLAYLISTS', + 'media_content_type': 'playlist', + 'thumbnail': None, + 'title': 'Playlists', + }), + ]) +# --- +# name: test_browse_media_library_albums + list([ + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': "A:ALBUM/A%20Hard%20Day's%20Night", + 'media_content_type': 'album', + 'thumbnail': "http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/A%20Hard%20Day's%20Night/01%20A%20Hard%20Day's%20Night%201.m4a&v=53", + 'title': "A Hard Day's Night", + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM/Abbey%20Road', + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/AbbeyA%20Road/01%20Come%20Together.m4a&v=53', + 'title': 'Abbey Road', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM/Between%20Good%20And%20Evil', + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/Santana/A%20Between%20Good%20And%20Evil/02%20A%20Persuasion.m4a&v=53', + 'title': 'Between Good And Evil', + }), + ]) +# --- +# name: test_browse_media_root + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png', + 'title': 'Favorites', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'library', + 'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png', + 'title': 'Music Library', + }), + ]) +# --- diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index d8d0e1c3a07..4f6c2f53d8b 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -2,6 +2,8 @@ from functools import partial +from syrupy import SnapshotAssertion + from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.components.media_player.const import MediaClass, MediaType from homeassistant.components.sonos.media_browser import ( @@ -12,6 +14,8 @@ from homeassistant.core import HomeAssistant from .conftest import SoCoMockFactory +from tests.typing import WebSocketGenerator + class MockMusicServiceItem: """Mocks a Soco MusicServiceItem.""" @@ -95,3 +99,81 @@ async def test_build_item_response( browse_item.children[1].media_content_id == "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3" ) + + +async def test_browse_media_root( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_media_library( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": "", + "media_content_type": "library", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_media_library_albums( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": "A:ALBUM", + "media_content_type": "album", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + assert soco_mock.music_library.browse_by_idstring.call_count == 1 From 85f0fffa5a9538eb135875ae4809aa2ff366873c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 18 May 2024 14:45:42 +0300 Subject: [PATCH 092/164] Filter out HTML greater/less than entities from huawei_lte sensor values (#117209) --- homeassistant/components/huawei_lte/sensor.py | 2 +- tests/components/huawei_lte/test_sensor.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index cef5bc5030e..5c5f7fc8b8e 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -54,7 +54,7 @@ def format_default(value: StateType) -> tuple[StateType, str | None]: if value is not None: # Clean up value and infer unit, e.g. -71dBm, 15 dB if match := re.match( - r"([>=<]*)(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) + r"((&[gl]t;|[><])=?)?(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) ): try: value = float(match.group("value")) diff --git a/tests/components/huawei_lte/test_sensor.py b/tests/components/huawei_lte/test_sensor.py index 4d5acaf2d31..75cdc7be1c2 100644 --- a/tests/components/huawei_lte/test_sensor.py +++ b/tests/components/huawei_lte/test_sensor.py @@ -15,6 +15,8 @@ from homeassistant.const import ( ("-71 dBm", (-71, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)), ("15dB", (15, SIGNAL_STRENGTH_DECIBELS)), (">=-51dBm", (-51, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)), + ("<-20dB", (-20, SIGNAL_STRENGTH_DECIBELS)), + (">=30dB", (30, SIGNAL_STRENGTH_DECIBELS)), ], ) def test_format_default(value, expected) -> None: From c6a9388aeac0e4ae493dad9c51d2a36377c90616 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 18 May 2024 12:39:58 +0200 Subject: [PATCH 093/164] Add options-property to Plugwise Select (#117655) --- homeassistant/components/plugwise/select.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 10718a818ff..a3e2a567e85 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -91,13 +91,17 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): super().__init__(coordinator, device_id) self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" - self._attr_options = self.device[entity_description.options_key] @property def current_option(self) -> str: """Return the selected entity option to represent the entity state.""" return self.device[self.entity_description.key] + @property + def options(self) -> list[str]: + """Return the available select-options.""" + return self.device[self.entity_description.options_key] + async def async_select_option(self, option: str) -> None: """Change to the selected entity option.""" await self.entity_description.command( From ecb587c4ca4451eee116929efd613382e2462c35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 May 2024 14:09:21 -1000 Subject: [PATCH 094/164] Fix setting MQTT socket buffer size with WebsocketWrapper (#117672) --- homeassistant/components/mqtt/client.py | 8 ++++++ tests/components/mqtt/test_init.py | 37 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 8245363fd85..e6e4bb52049 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -542,6 +542,14 @@ class MQTT: def _increase_socket_buffer_size(self, sock: SocketType) -> None: """Increase the socket buffer size.""" + if not hasattr(sock, "setsockopt") and hasattr(sock, "_socket"): + # The WebsocketWrapper does not wrap setsockopt + # so we need to get the underlying socket + # Remove this once + # https://github.com/eclipse/paho.mqtt.python/pull/843 + # is available. + sock = sock._socket # noqa: SLF001 + new_buffer_size = PREFERRED_BUFFER_SIZE while True: try: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 448d41c59cc..6ead70e4150 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -4410,6 +4410,43 @@ async def test_server_sock_buffer_size( assert "Unable to increase the socket buffer size" in caplog.text +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_server_sock_buffer_size_with_websocket( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling the socket buffer size fails.""" + mqtt_mock = await mqtt_mock_entry() + await hass.async_block_till_done() + assert mqtt_mock.connected is True + + mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_SUCCESS + + client, server = socket.socketpair( + family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0 + ) + client.setblocking(False) + server.setblocking(False) + + class FakeWebsocket(paho_mqtt.WebsocketWrapper): + def _do_handshake(self, *args, **kwargs): + pass + + wrapped_socket = FakeWebsocket(client, "127.0.01", 1, False, "/", None) + + with patch.object(client, "setsockopt", side_effect=OSError("foo")): + mqtt_client_mock.on_socket_open(mqtt_client_mock, None, wrapped_socket) + mqtt_client_mock.on_socket_register_write( + mqtt_client_mock, None, wrapped_socket + ) + await hass.async_block_till_done() + assert "Unable to increase the socket buffer size" in caplog.text + + @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) From 66fccb72967992fa7ce3c39274b3e84b498f3e16 Mon Sep 17 00:00:00 2001 From: On Freund Date: Sat, 18 May 2024 12:37:24 +0300 Subject: [PATCH 095/164] Bump pyrisco to 0.6.2 (#117682) --- homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 22e73a10d6d..25520d1f96e 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyrisco"], "quality_scale": "platinum", - "requirements": ["pyrisco==0.6.1"] + "requirements": ["pyrisco==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 675b01a31b9..b5ffb75bc2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2093,7 +2093,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.1 +pyrisco==0.6.2 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c313ef952a3..3f91c7fe76d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ pyqwikswitch==0.93 pyrainbird==4.0.2 # homeassistant.components.risco -pyrisco==0.6.1 +pyrisco==0.6.2 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 From 66c52e144e8e1e07ebbd7837c48ba141df85fabf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 18 May 2024 16:38:22 +0200 Subject: [PATCH 096/164] Consider only active config entries as media source in Synology DSM (#117691) consider only active config entries as media source --- homeassistant/components/synology_dsm/media_source.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 4699a1a5c20..4b0c19b2b55 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -27,7 +27,9 @@ from .models import SynologyDSMData async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Synology media source.""" - entries = hass.config_entries.async_entries(DOMAIN) + entries = hass.config_entries.async_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) hass.http.register_view(SynologyDsmMediaView(hass)) return SynologyPhotosMediaSource(hass, entries) From b44821b805ab9fdb1c1d5b1004dd880afa0fdb73 Mon Sep 17 00:00:00 2001 From: Anrijs Date: Sun, 19 May 2024 21:08:39 +0300 Subject: [PATCH 097/164] Bump aranet4 to 2.3.4 (#117738) bump aranet4 lib version to 2.3.4 --- homeassistant/components/aranet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index 152c56e80f3..f7f831df05c 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.3.3"] + "requirements": ["aranet4==2.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b5ffb75bc2b..1aa74face86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,7 +461,7 @@ aprslib==0.7.2 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.3.3 +aranet4==2.3.4 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f91c7fe76d..e8004593afb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -422,7 +422,7 @@ apprise==1.7.4 aprslib==0.7.2 # homeassistant.components.aranet -aranet4==2.3.3 +aranet4==2.3.4 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 From 8d24f68f55c0492d48ab1190e30d4828608fbef4 Mon Sep 17 00:00:00 2001 From: Ricardo Steijn <61013287+RicArch97@users.noreply.github.com> Date: Mon, 20 May 2024 07:18:28 +0200 Subject: [PATCH 098/164] Bump crownstone-sse to 2.0.5, crownstone-cloud to 1.4.11 (#117748) --- homeassistant/components/crownstone/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json index 532fd859b4e..6168d483ab5 100644 --- a/homeassistant/components/crownstone/manifest.json +++ b/homeassistant/components/crownstone/manifest.json @@ -13,8 +13,8 @@ "crownstone_uart" ], "requirements": [ - "crownstone-cloud==1.4.9", - "crownstone-sse==2.0.4", + "crownstone-cloud==1.4.11", + "crownstone-sse==2.0.5", "crownstone-uart==2.1.0", "pyserial==3.5" ] diff --git a/requirements_all.txt b/requirements_all.txt index 1aa74face86..46d2b49461a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -670,10 +670,10 @@ construct==2.10.68 croniter==2.0.2 # homeassistant.components.crownstone -crownstone-cloud==1.4.9 +crownstone-cloud==1.4.11 # homeassistant.components.crownstone -crownstone-sse==2.0.4 +crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8004593afb..f8d3da0fc2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -554,10 +554,10 @@ construct==2.10.68 croniter==2.0.2 # homeassistant.components.crownstone -crownstone-cloud==1.4.9 +crownstone-cloud==1.4.11 # homeassistant.components.crownstone -crownstone-sse==2.0.4 +crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 From 56b55a0df5217ae6b3dbde733b0749f3dd6bd364 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 May 2024 21:45:52 -1000 Subject: [PATCH 099/164] Block older versions of custom integration mydolphin_plus since they cause crashes (#117751) --- homeassistant/loader.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 89c3442be6a..b65d6f34f7b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -96,6 +96,11 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { "dreame_vacuum": BlockedIntegration( AwesomeVersion("1.0.4"), "crashes Home Assistant" ), + # Added in 2024.5.5 because of + # https://github.com/sh00t2kill/dolphin-robot/issues/185 + "mydolphin_plus": BlockedIntegration( + AwesomeVersion("1.0.13"), "crashes Home Assistant" + ), } DATA_COMPONENTS = "components" From dae4d316ae9db4813b6ab52e69551f09f2b75940 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 May 2024 21:47:47 -1000 Subject: [PATCH 100/164] Fix race in config entry setup (#117756) --- homeassistant/config_entries.py | 11 +++++ tests/test_config_entries.py | 83 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f982f63b948..9635d5cba48 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -709,6 +709,17 @@ class ConfigEntry: ) -> None: """Set up while holding the setup lock.""" async with self.setup_lock: + if self.state is ConfigEntryState.LOADED: + # If something loaded the config entry while + # we were waiting for the lock, we should not + # set it up again. + _LOGGER.debug( + "Not setting up %s (%s %s) again, already loaded", + self.title, + self.domain, + self.entry_id, + ) + return await self.async_setup(hass, integration=integration) @callback diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 8d7efad8918..9c491987d79 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -90,6 +90,89 @@ async def manager(hass: HomeAssistant) -> config_entries.ConfigEntries: return manager +async def test_setup_race_only_setup_once(hass: HomeAssistant) -> None: + """Test ensure that config entries are only setup once.""" + attempts = 0 + slow_config_entry_setup_future = hass.loop.create_future() + fast_config_entry_setup_future = hass.loop.create_future() + slow_setup_future = hass.loop.create_future() + + async def async_setup(hass, config): + """Mock setup.""" + await slow_setup_future + return True + + async def async_setup_entry(hass, entry): + """Mock setup entry.""" + slow = entry.data["slow"] + if slow: + await slow_config_entry_setup_future + return True + nonlocal attempts + attempts += 1 + if attempts == 1: + raise ConfigEntryNotReady + await fast_config_entry_setup_future + return True + + async def async_unload_entry(hass, entry): + """Mock unload entry.""" + return True + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + entry = MockConfigEntry(domain="comp", data={"slow": False}) + entry.add_to_hass(hass) + + entry2 = MockConfigEntry(domain="comp", data={"slow": True}) + entry2.add_to_hass(hass) + await entry2.setup_lock.acquire() + + async def _async_reload_entry(entry: MockConfigEntry): + async with entry.setup_lock: + await entry.async_unload(hass) + await entry.async_setup(hass) + + hass.async_create_task(_async_reload_entry(entry2)) + + setup_task = hass.async_create_task(async_setup_component(hass, "comp", {})) + entry2.setup_lock.release() + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert entry2.state is config_entries.ConfigEntryState.NOT_LOADED + + assert "comp" not in hass.config.components + slow_setup_future.set_result(None) + await asyncio.sleep(0) + assert "comp" in hass.config.components + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + assert entry2.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS + + fast_config_entry_setup_future.set_result(None) + # Make sure setup retry is started + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + slow_config_entry_setup_future.set_result(None) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + await hass.async_block_till_done() + + assert attempts == 2 + await hass.async_block_till_done() + assert setup_task.done() + assert entry2.state is config_entries.ConfigEntryState.LOADED + + async def test_call_setup_entry(hass: HomeAssistant) -> None: """Test we call .setup_entry.""" entry = MockConfigEntry(domain="comp") From db73074185fa3d69dd3392852bb19ddbffc5e0f8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 May 2024 13:49:52 +0200 Subject: [PATCH 101/164] Update wled to 0.18.0 (#117790) --- homeassistant/components/wled/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index fd15d8ef171..a01bbcabdd6 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.17.1"], + "requirements": ["wled==0.18.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 46d2b49461a..fc909232f2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2866,7 +2866,7 @@ wiffi==1.1.2 wirelesstagpy==0.8.1 # homeassistant.components.wled -wled==0.17.1 +wled==0.18.0 # homeassistant.components.wolflink wolf-comm==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8d3da0fc2e..fc12be7e292 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2222,7 +2222,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wled -wled==0.17.1 +wled==0.18.0 # homeassistant.components.wolflink wolf-comm==0.0.7 From 6956d0d65a6bc9d3140b99012cd0f59831446561 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 20 May 2024 21:35:57 -0400 Subject: [PATCH 102/164] Account for disabled ZHA discovery config entries when migrating SkyConnect integration (#117800) * Properly handle disabled ZHA discovery config entries * Update tests/components/homeassistant_sky_connect/test_util.py Co-authored-by: TheJulianJES --------- Co-authored-by: TheJulianJES --- .../homeassistant_sky_connect/util.py | 18 ++++++++++-------- .../homeassistant_sky_connect/test_util.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py index f242416fa9a..864d6bfd9dc 100644 --- a/homeassistant/components/homeassistant_sky_connect/util.py +++ b/homeassistant/components/homeassistant_sky_connect/util.py @@ -50,9 +50,9 @@ def get_hardware_variant(config_entry: ConfigEntry) -> HardwareVariant: return HardwareVariant.from_usb_product_name(config_entry.data["product"]) -def get_zha_device_path(config_entry: ConfigEntry) -> str: +def get_zha_device_path(config_entry: ConfigEntry) -> str | None: """Get the device path from a ZHA config entry.""" - return cast(str, config_entry.data["device"]["path"]) + return cast(str | None, config_entry.data.get("device", {}).get("path", None)) @singleton(OTBR_ADDON_MANAGER_DATA) @@ -94,13 +94,15 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN): zha_path = get_zha_device_path(zha_config_entry) - device_guesses[zha_path].append( - FirmwareGuess( - is_running=(zha_config_entry.state == ConfigEntryState.LOADED), - firmware_type=ApplicationType.EZSP, - source="zha", + + if zha_path is not None: + device_guesses[zha_path].append( + FirmwareGuess( + is_running=(zha_config_entry.state == ConfigEntryState.LOADED), + firmware_type=ApplicationType.EZSP, + source="zha", + ) ) - ) if is_hassio(hass): otbr_addon_manager = get_otbr_addon_manager(hass) diff --git a/tests/components/homeassistant_sky_connect/test_util.py b/tests/components/homeassistant_sky_connect/test_util.py index 12ba352eb16..b560acc65b7 100644 --- a/tests/components/homeassistant_sky_connect/test_util.py +++ b/tests/components/homeassistant_sky_connect/test_util.py @@ -94,6 +94,18 @@ def test_get_zha_device_path() -> None: ) +def test_get_zha_device_path_ignored_discovery() -> None: + """Test extracting the ZHA device path from an ignored ZHA discovery.""" + config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data={}, + version=4, + ) + + assert get_zha_device_path(config_entry) is None + + async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" From 0fb5aaf0f827e631c467345eb6a5d239f290d552 Mon Sep 17 00:00:00 2001 From: Bernardus Jansen Date: Tue, 21 May 2024 10:00:29 +0200 Subject: [PATCH 103/164] Tesla Wall Connector fix spelling error/typo (#117841) --- homeassistant/components/tesla_wall_connector/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index ed1878caecb..e8f73f22d20 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -37,7 +37,7 @@ "not_connected": "Vehicle not connected", "connected": "Vehicle connected", "ready": "Ready to charge", - "negociating": "Negociating connection", + "negotiating": "Negotiating connection", "error": "Error", "charging_finished": "Charging finished", "waiting_car": "Waiting for car", From 7d5f9b1adf52c89645a8ab7b906a6dd3bef65fac Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 22 May 2024 22:36:03 +0200 Subject: [PATCH 104/164] Prevent time pattern reschedule if cancelled during job execution (#117879) Co-authored-by: J. Nick Koston --- homeassistant/helpers/event.py | 2 +- tests/helpers/test_event.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5c026064c28..67b057463dd 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1766,7 +1766,6 @@ class _TrackUTCTimeChange: # time when the timer was scheduled utc_now = time_tracker_utcnow() localized_now = dt_util.as_local(utc_now) if self.local else utc_now - hass.async_run_hass_job(self.job, localized_now, background=True) if TYPE_CHECKING: assert self._pattern_time_change_listener_job is not None self._cancel_callback = async_track_point_in_utc_time( @@ -1774,6 +1773,7 @@ class _TrackUTCTimeChange: self._pattern_time_change_listener_job, self._calculate_next(utc_now + timedelta(seconds=1)), ) + hass.async_run_hass_job(self.job, localized_now, background=True) @callback def async_cancel(self) -> None: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a6fad968eac..7fb02024170 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4589,6 +4589,40 @@ async def test_async_track_point_in_time_cancel(hass: HomeAssistant) -> None: assert "US/Hawaii" in str(times[0].tzinfo) +async def test_async_track_point_in_time_cancel_in_job( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test cancel of async track point in time during job execution.""" + + now = dt_util.utcnow() + times = [] + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC + ) + freezer.move_to(time_that_will_not_match_right_away) + + @callback + def action(x: datetime): + nonlocal times + times.append(x) + unsub() + + unsub = async_track_utc_time_change(hass, action, minute=0, second="*") + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) + ) + await hass.async_block_till_done() + assert len(times) == 1 + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 13, 0, 0, 999999, tzinfo=dt_util.UTC) + ) + await hass.async_block_till_done() + assert len(times) == 1 + + async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> None: """Test tracking entity registry updates for an entity_id.""" From 7e18527dfb6bb31717e4ce17db5364fc2f0d56ad Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 22 May 2024 00:11:10 +0200 Subject: [PATCH 105/164] Update philips_js to 3.2.1 (#117881) * Update philips_js to 3.2.0 * Update to 3.2.1 --- homeassistant/components/philips_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 4751e85d378..b4ca9b931a7 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/philips_js", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.1.1"] + "requirements": ["ha-philipsjs==3.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc909232f2f..3b7b854a2b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,7 +1029,7 @@ ha-ffmpeg==3.2.0 ha-iotawattpy==0.1.2 # homeassistant.components.philips_js -ha-philipsjs==3.1.1 +ha-philipsjs==3.2.1 # homeassistant.components.habitica habitipy==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc12be7e292..307c3b39d3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -843,7 +843,7 @@ ha-ffmpeg==3.2.0 ha-iotawattpy==0.1.2 # homeassistant.components.philips_js -ha-philipsjs==3.1.1 +ha-philipsjs==3.2.1 # homeassistant.components.habitica habitipy==0.2.0 From ac97f25d6ce3433c0ef807ff6f66b3c9e2aa7911 Mon Sep 17 00:00:00 2001 From: On Freund Date: Wed, 22 May 2024 19:14:04 +0300 Subject: [PATCH 106/164] Bump pyrympro to 0.0.8 (#117919) --- homeassistant/components/rympro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index e14ac9af71f..046e778f05b 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.7"] + "requirements": ["pyrympro==0.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3b7b854a2b0..4ef01b9b52e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2102,7 +2102,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.7 +pyrympro==0.0.8 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 307c3b39d3e..9fd712fbab3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1644,7 +1644,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.7 +pyrympro==0.0.8 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 From 09779b5f6ee8b56f38961f63bb65f58f8105ec44 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 23 May 2024 06:15:15 +0300 Subject: [PATCH 107/164] Add Shelly debug logging for async_reconnect_soon (#117945) --- homeassistant/components/shelly/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 46cea4e49a4..ccc86c564d5 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -256,6 +256,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): if ( current_entry := await self.async_set_unique_id(mac) ) and current_entry.data.get(CONF_HOST) == host: + LOGGER.debug("async_reconnect_soon: host: %s, mac: %s", host, mac) await async_reconnect_soon(self.hass, current_entry) if host == INTERNAL_WIFI_AP_IP: # If the device is broadcasting the internal wifi ap ip From f4b653a7678a15e5d22945eca41f404786388184 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 May 2024 17:59:44 -1000 Subject: [PATCH 108/164] Update pySwitchbot to 0.46.0 to fix lock key retrieval (#118005) * Update pySwitchbot to 0.46.0 to fix lock key retrieval needs https://github.com/Danielhiversen/pySwitchbot/pull/236 * bump * fixes --- homeassistant/components/switchbot/config_flow.py | 15 +++++++++++---- homeassistant/components/switchbot/manifest.json | 2 +- homeassistant/components/switchbot/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/switchbot/test_config_flow.py | 11 ++++++----- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 06b95c6f8aa..bb69da52239 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -8,6 +8,7 @@ from typing import Any from switchbot import ( SwitchbotAccountConnectionError, SwitchBotAdvertisement, + SwitchbotApiError, SwitchbotAuthenticationError, SwitchbotLock, SwitchbotModel, @@ -33,6 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_ENCRYPTION_KEY, @@ -175,14 +177,19 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders = {} if user_input is not None: try: - key_details = await self.hass.async_add_executor_job( - SwitchbotLock.retrieve_encryption_key, + key_details = await SwitchbotLock.async_retrieve_encryption_key( + async_get_clientsession(self.hass), self._discovered_adv.address, user_input[CONF_USERNAME], user_input[CONF_PASSWORD], ) - except SwitchbotAccountConnectionError as ex: - raise AbortFlow("cannot_connect") from ex + except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex: + _LOGGER.debug( + "Failed to connect to SwitchBot API: %s", ex, exc_info=True + ) + raise AbortFlow( + "api_error", description_placeholders={"error_detail": str(ex)} + ) from ex except SwitchbotAuthenticationError as ex: _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) errors = {"base": "auth_failed"} diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 401d85e7376..ba4782c8b63 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.45.0"] + "requirements": ["PySwitchbot==0.46.0"] } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 8eab1ec6f1a..a20b4939f8f 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -46,7 +46,7 @@ "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "No supported SwitchBot devices found in range; If the device is in range, ensure the scanner has active scanning enabled, as SwitchBot devices cannot be discovered with passive scans. Active scans can be disabled once the device is configured. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the SwitchBot device is present.", "unknown": "[%key:common::config_flow::error::unknown%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "api_error": "Error while communicating with SwitchBot API: {error_detail}", "switchbot_unsupported_type": "Unsupported Switchbot Type." } }, diff --git a/requirements_all.txt b/requirements_all.txt index 4ef01b9b52e..34cce0f7dc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -93,7 +93,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.45.0 +PySwitchbot==0.46.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9fd712fbab3..26e704781e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.45.0 +PySwitchbot==0.46.0 # homeassistant.components.syncthru PySyncThru==0.7.10 diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index a62a100f55a..182e9457f22 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -487,7 +487,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", side_effect=SwitchbotAuthenticationError("error from api"), ): result = await hass.config_entries.flow.async_configure( @@ -510,7 +510,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: return_value=True, ), patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", return_value={ CONF_KEY_ID: "ff", CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", @@ -560,8 +560,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", - side_effect=SwitchbotAccountConnectionError, + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", + side_effect=SwitchbotAccountConnectionError("Switchbot API down"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -572,7 +572,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == "api_error" + assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: From 3238bc83b8711ce1a72915d179ef658dc20a31c0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 May 2024 09:55:05 +0200 Subject: [PATCH 109/164] Improve async_get_issue_tracker for custom integrations (#118016) --- homeassistant/bootstrap.py | 3 +++ homeassistant/loader.py | 8 ++++++++ tests/test_loader.py | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index fc5eedffc39..f733c6f9ff1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -414,6 +414,9 @@ async def async_from_config_dict( start = monotonic() hass.config_entries = config_entries.ConfigEntries(hass, config) + # Prime custom component cache early so we know if registry entries are tied + # to a custom integration + await loader.async_get_custom_components(hass) await async_load_base_functionality(hass) # Set up core. diff --git a/homeassistant/loader.py b/homeassistant/loader.py index b65d6f34f7b..d8b32b053db 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1674,6 +1674,14 @@ def async_get_issue_tracker( # If we know nothing about the entity, suggest opening an issue on HA core return issue_tracker + if ( + not integration + and (hass and integration_domain) + and (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS)) + and not isinstance(comps_or_future, asyncio.Future) + ): + integration = comps_or_future.get(integration_domain) + if not integration and (hass and integration_domain): with suppress(IntegrationNotLoaded): integration = async_get_loaded_integration(hass, integration_domain) diff --git a/tests/test_loader.py b/tests/test_loader.py index 404858200bc..09afdf1504b 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2,6 +2,7 @@ import asyncio import os +import pathlib import sys import threading from typing import Any @@ -1108,14 +1109,18 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" # Integration domain is not currently deduced from module (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), - # Custom integration with known issue tracker + # Loaded custom integration with known issue tracker ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", None, CUSTOM_ISSUE_TRACKER), - # Custom integration without known issue tracker + # Loaded custom integration without known issue tracker (None, "custom_components.bla.sensor", None), ("bla_custom_no_tracker", "custom_components.bla_custom.sensor", None), ("bla_custom_no_tracker", None, None), ("hue", "custom_components.bla.sensor", None), + # Unloaded custom integration with known issue tracker + ("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER), + # Unloaded custom integration without known issue tracker + ("bla_custom_not_loaded_no_tracker", None, None), # Integration domain has priority over module ("bla_custom_no_tracker", "homeassistant.components.bla_custom.sensor", None), ], @@ -1133,6 +1138,32 @@ async def test_async_get_issue_tracker( built_in=False, ) mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False) + + cust_unloaded_module = MockModule( + "bla_custom_not_loaded", + partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER}, + ) + cust_unloaded = loader.Integration( + hass, + f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_module.DOMAIN}", + pathlib.Path(""), + cust_unloaded_module.mock_manifest(), + set(), + ) + + cust_unloaded_no_tracker_module = MockModule("bla_custom_not_loaded_no_tracker") + cust_unloaded_no_tracker = loader.Integration( + hass, + f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_no_tracker_module.DOMAIN}", + pathlib.Path(""), + cust_unloaded_no_tracker_module.mock_manifest(), + set(), + ) + hass.data["custom_components"] = { + "bla_custom_not_loaded": cust_unloaded, + "bla_custom_not_loaded_no_tracker": cust_unloaded_no_tracker, + } + assert ( loader.async_get_issue_tracker(hass, integration_domain=domain, module=module) == issue_tracker From f5c20b3528c0c5c1cfca2260711680d3c319892e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 May 2024 22:37:10 -1000 Subject: [PATCH 110/164] Bump pySwitchbot to 0.46.1 (#118025) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index ba4782c8b63..2388e5a98b3 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.46.0"] + "requirements": ["PySwitchbot==0.46.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 34cce0f7dc1..bd747808819 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -93,7 +93,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.46.0 +PySwitchbot==0.46.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26e704781e7..716abc3edd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.46.0 +PySwitchbot==0.46.1 # homeassistant.components.syncthru PySyncThru==0.7.10 From 81bf31bbb16ad570604aafaecadda6d36c9f55b0 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 24 May 2024 13:50:10 +0200 Subject: [PATCH 111/164] Extend the blocklist for Matter transitions with more models (#118038) --- homeassistant/components/matter/light.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index da72798dda1..acd85884875 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -52,7 +52,10 @@ DEFAULT_TRANSITION = 0.2 # sw version (attributeKey 0/40/10) TRANSITION_BLOCKLIST = ( (4488, 514, "1.0", "1.0.0"), + (4488, 260, "1.0", "1.0.0"), (5010, 769, "3.0", "1.0.0"), + (4999, 25057, "1.0", "27.0"), + (4448, 36866, "V1", "V1.0.0.5"), ) From 3f7e57dde224587b6559f67fbc011885ec1578b5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 May 2024 16:13:44 +0200 Subject: [PATCH 112/164] Bump version to 2024.5.5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 278050b69e1..e0832f7cc85 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 1805545235f..b84159eb457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.4" +version = "2024.5.5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 8128449879c557e72161584c2e383608e48219a2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 May 2024 18:41:46 +0200 Subject: [PATCH 113/164] Fix rc pylint warning in MQTT (#118050) --- homeassistant/components/mqtt/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e6e4bb52049..0261512fe99 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -548,7 +548,7 @@ class MQTT: # Remove this once # https://github.com/eclipse/paho.mqtt.python/pull/843 # is available. - sock = sock._socket # noqa: SLF001 + sock = sock._socket # pylint: disable=protected-access new_buffer_size = PREFERRED_BUFFER_SIZE while True: From 750ec261be3891d5fde345455317b7855e2cb5ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 May 2024 17:09:28 -0500 Subject: [PATCH 114/164] Add state check to config entry setup to ensure it cannot be setup twice (#117193) --- homeassistant/config_entries.py | 9 +++++ tests/components/upnp/test_config_flow.py | 8 ++-- tests/test_config_entries.py | 46 +++++++++++++++++++++-- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9635d5cba48..252f7be8b7e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -514,6 +514,15 @@ class ConfigEntry: # Only store setup result as state if it was not forwarded. if domain_is_integration := self.domain == integration.domain: + if self.state in ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_IN_PROGRESS, + ): + raise OperationNotAllowed( + f"The config entry {self.title} ({self.domain}) with entry_id" + f" {self.entry_id} cannot be setup because is already loaded in the" + f" {self.state} state" + ) self._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) if self.supports_unload is None: diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index a3d2b97f3ed..a4598346a51 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -196,7 +196,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_mac(hass: HomeAssistant) -> CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -228,7 +228,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_host(hass: HomeAssistant) - CONFIG_ENTRY_HOST: TEST_HOST, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -266,7 +266,7 @@ async def test_flow_ssdp_discovery_changed_udn_but_st_differs( CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -320,7 +320,7 @@ async def test_flow_ssdp_discovery_changed_location(hass: HomeAssistant) -> None CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 9c491987d79..1394ca1e435 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -469,7 +469,7 @@ async def test_remove_entry( ] # Setup entry - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() # Check entity state got added @@ -1696,7 +1696,9 @@ async def test_entry_reload_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can reload an entry.""" - entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) entry.add_to_hass(hass) async_setup = AsyncMock(return_value=True) @@ -1720,6 +1722,42 @@ async def test_entry_reload_succeed( assert entry.state is config_entries.ConfigEntryState.LOADED +@pytest.mark.parametrize( + "state", + [ + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.SETUP_IN_PROGRESS, + ], +) +async def test_entry_cannot_be_loaded_twice( + hass: HomeAssistant, state: config_entries.ConfigEntryState +) -> None: + """Test that a config entry cannot be loaded twice.""" + entry = MockConfigEntry(domain="comp", state=state) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + with pytest.raises(config_entries.OperationNotAllowed, match=str(state)): + await entry.async_setup(hass) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state is state + + @pytest.mark.parametrize( "state", [ @@ -4088,7 +4126,9 @@ async def test_entry_reload_concurrency_not_setup_setup( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test multiple reload calls do not cause a reload race.""" - entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) entry.add_to_hass(hass) async_setup = AsyncMock(return_value=True) From c59d4f9bba8b2761fddd760d8261fc4a72530790 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 May 2024 00:00:04 -0400 Subject: [PATCH 115/164] Add no-API LLM prompt back to Google (#118082) * Add no-API LLM prompt back * Use string join --- .../config_flow.py | 3 +- .../conversation.py | 28 +++++++++++-------- .../snapshots/test_conversation.ambr | 10 +++++-- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 50b626f553c..b559888cc5f 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -181,8 +181,7 @@ async def google_generative_ai_config_option_schema( schema = { vol.Optional( CONF_PROMPT, - description={"suggested_value": options.get(CONF_PROMPT)}, - default=DEFAULT_PROMPT, + description={"suggested_value": options.get(CONF_PROMPT, DEFAULT_PROMPT)}, ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index ad50c544ac7..21d26ab5616 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -205,15 +205,6 @@ class GoogleGenerativeAIConversationEntity( messages = [{}, {}] try: - prompt = template.Template( - self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass - ).async_render( - { - "ha_name": self.hass.config.location_name, - }, - parse_result=False, - ) - if llm_api: empty_tool_input = llm.ToolInput( tool_name="", @@ -226,9 +217,24 @@ class GoogleGenerativeAIConversationEntity( device_id=user_input.device_id, ) - prompt = ( - await llm_api.async_get_api_prompt(empty_tool_input) + "\n" + prompt + api_prompt = await llm_api.async_get_api_prompt(empty_tool_input) + + else: + api_prompt = llm.PROMPT_NO_API_CONFIGURED + + prompt = "\n".join( + ( + api_prompt, + template.Template( + self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass + ).async_render( + { + "ha_name": self.hass.config.location_name, + }, + parse_result=False, + ), ) + ) except TemplateError as err: LOGGER.error("Error rendering prompt: %s", err) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index ebc918bbf31..112e1f91b55 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -29,7 +29,10 @@ dict({ 'history': list([ dict({ - 'parts': 'Answer in plain text. Keep it simple and to the point.', + 'parts': ''' + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + Answer in plain text. Keep it simple and to the point. + ''', 'role': 'user', }), dict({ @@ -79,7 +82,10 @@ dict({ 'history': list([ dict({ - 'parts': 'Answer in plain text. Keep it simple and to the point.', + 'parts': ''' + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + Answer in plain text. Keep it simple and to the point. + ''', 'role': 'user', }), dict({ From 676fe5a9a21b5cfc4bd0b7db3e52e3ddf3bc8575 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 May 2024 00:01:48 -0400 Subject: [PATCH 116/164] Add recommended model options to OpenAI (#118083) * Add recommended options to OpenAI * Use string join --- .../openai_conversation/config_flow.py | 109 +++++++++++------- .../components/openai_conversation/const.py | 10 +- .../openai_conversation/conversation.py | 60 +++++----- .../openai_conversation/strings.json | 3 +- .../openai_conversation/test_config_flow.py | 87 +++++++++++++- 5 files changed, 192 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index af1ec3d2fc6..09b909b3d5e 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -31,14 +31,15 @@ from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, - DEFAULT_CHAT_MODEL, - DEFAULT_MAX_TOKENS, DEFAULT_PROMPT, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_P, DOMAIN, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, ) _LOGGER = logging.getLogger(__name__) @@ -49,6 +50,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_PROMPT: DEFAULT_PROMPT, +} + async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -88,7 +95,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="ChatGPT", data=user_input, - options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, + options=RECOMMENDED_OPTIONS, ) return self.async_show_form( @@ -109,16 +116,32 @@ class OpenAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry + self.last_rendered_recommended = config_entry.options.get( + CONF_RECOMMENDED, False + ) async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + if user_input is not None: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) - schema = openai_config_option_schema(self.hass, self.config_entry.options) + if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if user_input[CONF_LLM_HASS_API] == "none": + user_input.pop(CONF_LLM_HASS_API) + return self.async_create_entry(title="", data=user_input) + + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } + + schema = openai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", data_schema=vol.Schema(schema), @@ -127,16 +150,16 @@ class OpenAIOptionsFlow(OptionsFlow): def openai_config_option_schema( hass: HomeAssistant, - options: MappingProxyType[str, Any], + options: dict[str, Any] | MappingProxyType[str, Any], ) -> dict: """Return a schema for OpenAI completion options.""" - apis: list[SelectOptionDict] = [ + hass_apis: list[SelectOptionDict] = [ SelectOptionDict( label="No control", value="none", ) ] - apis.extend( + hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, @@ -144,38 +167,46 @@ def openai_config_option_schema( for api in llm.async_get_apis(hass) ) - return { + schema = { vol.Optional( CONF_PROMPT, - description={"suggested_value": options.get(CONF_PROMPT)}, - default=DEFAULT_PROMPT, + description={"suggested_value": options.get(CONF_PROMPT, DEFAULT_PROMPT)}, ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, default="none", - ): SelectSelector(SelectSelectorConfig(options=apis)), - vol.Optional( - CONF_CHAT_MODEL, - description={ - # New key in HA 2023.4 - "suggested_value": options.get(CONF_CHAT_MODEL) - }, - default=DEFAULT_CHAT_MODEL, - ): str, - vol.Optional( - CONF_MAX_TOKENS, - description={"suggested_value": options.get(CONF_MAX_TOKENS)}, - default=DEFAULT_MAX_TOKENS, - ): int, - vol.Optional( - CONF_TOP_P, - description={"suggested_value": options.get(CONF_TOP_P)}, - default=DEFAULT_TOP_P, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), - vol.Optional( - CONF_TEMPERATURE, - description={"suggested_value": options.get(CONF_TEMPERATURE)}, - default=DEFAULT_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, } + + if options.get(CONF_RECOMMENDED): + return schema + + schema.update( + { + vol.Optional( + CONF_CHAT_MODEL, + description={"suggested_value": options.get(CONF_CHAT_MODEL)}, + default=RECOMMENDED_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options.get(CONF_TOP_P)}, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options.get(CONF_TEMPERATURE)}, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + } + ) + return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 27ef86bf918..995d80e02f1 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -4,13 +4,15 @@ import logging DOMAIN = "openai_conversation" LOGGER = logging.getLogger(__package__) + +CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" DEFAULT_PROMPT = """Answer in plain text. Keep it simple and to the point.""" CONF_CHAT_MODEL = "chat_model" -DEFAULT_CHAT_MODEL = "gpt-4o" +RECOMMENDED_CHAT_MODEL = "gpt-4o" CONF_MAX_TOKENS = "max_tokens" -DEFAULT_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 150 CONF_TOP_P = "top_p" -DEFAULT_TOP_P = 1.0 +RECOMMENDED_TOP_P = 1.0 CONF_TEMPERATURE = "temperature" -DEFAULT_TEMPERATURE = 1.0 +RECOMMENDED_TEMPERATURE = 1.0 diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index a878b934317..2e6e985f8fd 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -22,13 +22,13 @@ from .const import ( CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, - DEFAULT_CHAT_MODEL, - DEFAULT_MAX_TOKENS, DEFAULT_PROMPT, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_P, DOMAIN, LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, ) # Max number of back and forth with the LLM to generate a response @@ -97,15 +97,14 @@ class OpenAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" + options = self.entry.options intent_response = intent.IntentResponse(language=user_input.language) llm_api: llm.API | None = None tools: list[dict[str, Any]] | None = None - if self.entry.options.get(CONF_LLM_HASS_API): + if options.get(CONF_LLM_HASS_API): try: - llm_api = llm.async_get_api( - self.hass, self.entry.options[CONF_LLM_HASS_API] - ) + llm_api = llm.async_get_api(self.hass, options[CONF_LLM_HASS_API]) except HomeAssistantError as err: LOGGER.error("Error getting LLM API: %s", err) intent_response.async_set_error( @@ -117,26 +116,12 @@ class OpenAIConversationEntity( ) tools = [_format_tool(tool) for tool in llm_api.async_get_tools()] - model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - max_tokens = self.entry.options.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) - top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) - temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) - if user_input.conversation_id in self.history: conversation_id = user_input.conversation_id messages = self.history[conversation_id] else: conversation_id = ulid.ulid_now() try: - prompt = template.Template( - self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass - ).async_render( - { - "ha_name": self.hass.config.location_name, - }, - parse_result=False, - ) - if llm_api: empty_tool_input = llm.ToolInput( tool_name="", @@ -149,11 +134,24 @@ class OpenAIConversationEntity( device_id=user_input.device_id, ) - prompt = ( - await llm_api.async_get_api_prompt(empty_tool_input) - + "\n" - + prompt + api_prompt = await llm_api.async_get_api_prompt(empty_tool_input) + + else: + api_prompt = llm.PROMPT_NO_API_CONFIGURED + + prompt = "\n".join( + ( + api_prompt, + template.Template( + options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass + ).async_render( + { + "ha_name": self.hass.config.location_name, + }, + parse_result=False, + ), ) + ) except TemplateError as err: LOGGER.error("Error rendering prompt: %s", err) @@ -170,7 +168,7 @@ class OpenAIConversationEntity( messages.append({"role": "user", "content": user_input.text}) - LOGGER.debug("Prompt for %s: %s", model, messages) + LOGGER.debug("Prompt: %s", messages) client = self.hass.data[DOMAIN][self.entry.entry_id] @@ -178,12 +176,12 @@ class OpenAIConversationEntity( for _iteration in range(MAX_TOOL_ITERATIONS): try: result = await client.chat.completions.create( - model=model, + model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), messages=messages, tools=tools, - max_tokens=max_tokens, - top_p=top_p, - temperature=temperature, + max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), user=conversation_id, ) except openai.OpenAIError as err: diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 01060afc7f1..1e93c60b6a9 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -22,7 +22,8 @@ "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", "top_p": "Top P", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings" }, "data_description": { "prompt": "Instruct how the LLM should respond. This can be a template." diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 57f03d0c0bf..234e518b3c5 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -9,9 +9,17 @@ import pytest from homeassistant import config_entries from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, - DEFAULT_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_PROMPT, + CONF_RECOMMENDED, + CONF_TEMPERATURE, + CONF_TOP_P, DOMAIN, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TOP_P, ) +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -75,7 +83,7 @@ async def test_options( assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"]["prompt"] == "Speak like a pirate" assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL + assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL @pytest.mark.parametrize( @@ -115,3 +123,78 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error} + + +@pytest.mark.parametrize( + ("current_options", "new_options", "expected_options"), + [ + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "none", + CONF_PROMPT: "bla", + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "assist", + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "assist", + CONF_PROMPT: "", + }, + ), + ], +) +async def test_options_switching( + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + current_options, + new_options, + expected_options, +) -> None: + """Test the options form.""" + hass.config_entries.async_update_entry(mock_config_entry, options=current_options) + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + **current_options, + CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], + }, + ) + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + new_options, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == expected_options From 69f237fa9ea61fb769909ba46715c8f553c0f79b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 May 2024 00:02:53 -0400 Subject: [PATCH 117/164] Update Google safety defaults to match Google (#118084) --- .../const.py | 2 +- .../conversation.py | 14 ++++++-- .../snapshots/test_conversation.ambr | 32 +++++++++---------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 549883d4fb9..a83ffed2d88 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -22,4 +22,4 @@ CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" -RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_LOW_AND_ABOVE" +RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 21d26ab5616..f08c6c14e60 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -263,10 +263,20 @@ class GoogleGenerativeAIConversationEntity( genai_types.BlockedPromptException, genai_types.StopCandidateException, ) as err: - LOGGER.error("Error sending message: %s", err) + LOGGER.error("Error sending message: %s %s", type(err), err) + + if isinstance( + err, genai_types.StopCandidateException + ) and "finish_reason: SAFETY\n" in str(err): + error = "The message got blocked by your safety settings" + else: + error = ( + f"Sorry, I had a problem talking to Google Generative AI: {err}" + ) + intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to Google Generative AI: {err}", + error, ) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 112e1f91b55..d3a4f4b4b58 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -14,10 +14,10 @@ }), 'model_name': 'models/gemini-1.5-flash-latest', 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_LOW_AND_ABOVE', - 'HARASSMENT': 'BLOCK_LOW_AND_ABOVE', - 'HATE': 'BLOCK_LOW_AND_ABOVE', - 'SEXUAL': 'BLOCK_LOW_AND_ABOVE', + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', }), 'tools': None, }), @@ -67,10 +67,10 @@ }), 'model_name': 'models/gemini-1.5-flash-latest', 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_LOW_AND_ABOVE', - 'HARASSMENT': 'BLOCK_LOW_AND_ABOVE', - 'HATE': 'BLOCK_LOW_AND_ABOVE', - 'SEXUAL': 'BLOCK_LOW_AND_ABOVE', + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', }), 'tools': None, }), @@ -120,10 +120,10 @@ }), 'model_name': 'models/gemini-1.5-flash-latest', 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_LOW_AND_ABOVE', - 'HARASSMENT': 'BLOCK_LOW_AND_ABOVE', - 'HATE': 'BLOCK_LOW_AND_ABOVE', - 'SEXUAL': 'BLOCK_LOW_AND_ABOVE', + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', }), 'tools': None, }), @@ -173,10 +173,10 @@ }), 'model_name': 'models/gemini-1.5-flash-latest', 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_LOW_AND_ABOVE', - 'HARASSMENT': 'BLOCK_LOW_AND_ABOVE', - 'HATE': 'BLOCK_LOW_AND_ABOVE', - 'SEXUAL': 'BLOCK_LOW_AND_ABOVE', + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', }), 'tools': None, }), From 81f3387d06da742eba735bb26a88a3ddb2850f2c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 May 2024 00:33:24 -0400 Subject: [PATCH 118/164] Flip prompts to put user prompt on top (#118085) --- .../google_generative_ai_conversation/conversation.py | 2 +- .../components/openai_conversation/conversation.py | 2 +- .../snapshots/test_conversation.ambr | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index f08c6c14e60..627b28d0966 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -224,7 +224,6 @@ class GoogleGenerativeAIConversationEntity( prompt = "\n".join( ( - api_prompt, template.Template( self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass ).async_render( @@ -233,6 +232,7 @@ class GoogleGenerativeAIConversationEntity( }, parse_result=False, ), + api_prompt, ) ) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 2e6e985f8fd..2bd21429d9f 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -141,7 +141,6 @@ class OpenAIConversationEntity( prompt = "\n".join( ( - api_prompt, template.Template( options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass ).async_render( @@ -150,6 +149,7 @@ class OpenAIConversationEntity( }, parse_result=False, ), + api_prompt, ) ) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index d3a4f4b4b58..e1f8141a692 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -30,8 +30,8 @@ 'history': list([ dict({ 'parts': ''' - Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. ''', 'role': 'user', }), @@ -83,8 +83,8 @@ 'history': list([ dict({ 'parts': ''' - Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. ''', 'role': 'user', }), @@ -136,8 +136,8 @@ 'history': list([ dict({ 'parts': ''' - Call the intent tools to control Home Assistant. Just pass the name to the intent. Answer in plain text. Keep it simple and to the point. + Call the intent tools to control Home Assistant. Just pass the name to the intent. ''', 'role': 'user', }), @@ -189,8 +189,8 @@ 'history': list([ dict({ 'parts': ''' - Call the intent tools to control Home Assistant. Just pass the name to the intent. Answer in plain text. Keep it simple and to the point. + Call the intent tools to control Home Assistant. Just pass the name to the intent. ''', 'role': 'user', }), From ffcc9100a65a7516b6b6135271d2bd952708087c Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Sat, 25 May 2024 10:24:06 +0200 Subject: [PATCH 119/164] Bump velbusaio to 2024.5.1 (#118091) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 6f817a23325..f778533cad8 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.4.1"], + "requirements": ["velbus-aio==2024.5.1"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index f0e72b2398e..29374c54692 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2817,7 +2817,7 @@ vallox-websocket-api==5.1.1 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2024.4.1 +velbus-aio==2024.5.1 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1314534700d..ca926fb99ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2185,7 +2185,7 @@ vallox-websocket-api==5.1.1 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2024.4.1 +velbus-aio==2024.5.1 # homeassistant.components.venstar venstarcolortouch==0.19 From ad638dbcc509bf3827aa038693a3f0c43015fd56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 May 2024 22:28:14 -1000 Subject: [PATCH 120/164] Speed up removing MQTT subscriptions (#118088) --- homeassistant/components/mqtt/client.py | 10 +++++----- tests/components/mqtt/test_device_trigger.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 857b073a746..3e2507ade95 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -429,10 +429,10 @@ class MQTT: self.config_entry = config_entry self.conf = conf - self._simple_subscriptions: defaultdict[str, list[Subscription]] = defaultdict( - list + self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict( + set ) - self._wildcard_subscriptions: list[Subscription] = [] + self._wildcard_subscriptions: set[Subscription] = set() # _retained_topics prevents a Subscription from receiving a # retained message more than once per topic. This prevents flooding # already active subscribers when new subscribers subscribe to a topic @@ -789,9 +789,9 @@ class MQTT: The caller is responsible clearing the cache of _matching_subscriptions. """ if subscription.is_simple_match: - self._simple_subscriptions[subscription.topic].append(subscription) + self._simple_subscriptions[subscription.topic].add(subscription) else: - self._wildcard_subscriptions.append(subscription) + self._wildcard_subscriptions.add(subscription) @callback def _async_untrack_subscription(self, subscription: Subscription) -> None: diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 1ef80c0b81e..b01e40d311e 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -529,16 +529,16 @@ async def test_non_unique_triggers( async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data["some"] == "press1" - assert calls[1].data["some"] == "press2" + all_calls = {calls[0].data["some"], calls[1].data["some"]} + assert all_calls == {"press1", "press2"} # Trigger second config references to same trigger # and triggers both attached instances. async_fire_mqtt_message(hass, "foobar/triggers/button2", "long_press") await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data["some"] == "press1" - assert calls[1].data["some"] == "press2" + all_calls = {calls[0].data["some"], calls[1].data["some"]} + assert all_calls == {"press1", "press2"} # Removing the first trigger will clean up calls.clear() From 0ea14745567cc8181e9ebe9c17e0f6dd85b6ae90 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 May 2024 10:41:23 +0200 Subject: [PATCH 121/164] Store runtime data inside the config entry in Spotify (#117037) --- homeassistant/components/spotify/__init__.py | 10 ++--- .../components/spotify/browse_media.py | 41 +++++++++++-------- .../components/spotify/media_player.py | 7 ++-- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 8d5183a459d..9bf43609855 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -30,6 +30,7 @@ from .util import ( PLATFORMS = [Platform.MEDIA_PLAYER] +SpotifyConfigEntry = ConfigEntry["HomeAssistantSpotifyData"] __all__ = [ "async_browse_media", @@ -50,7 +51,7 @@ class HomeAssistantSpotifyData: session: OAuth2Session -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: """Set up Spotify from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) @@ -100,8 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await device_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = HomeAssistantSpotifyData( + entry.runtime_data = HomeAssistantSpotifyData( client=spotify, current_user=current_user, devices=device_coordinator, @@ -117,6 +117,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Spotify config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index cc8f57be1bb..a1d3d9c804a 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -5,7 +5,7 @@ from __future__ import annotations from enum import StrEnum from functools import partial import logging -from typing import Any +from typing import TYPE_CHECKING, Any from spotipy import Spotify import yarl @@ -22,6 +22,9 @@ from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES from .util import fetch_image_url +if TYPE_CHECKING: + from . import HomeAssistantSpotifyData + BROWSE_LIMIT = 48 @@ -140,21 +143,21 @@ async def async_browse_media( # Check if caller is requesting the root nodes if media_content_type is None and media_content_id is None: - children = [] - for config_entry_id in hass.data[DOMAIN]: - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry is not None - children.append( - BrowseMedia( - title=config_entry.title, - media_class=MediaClass.APP, - media_content_id=f"{MEDIA_PLAYER_PREFIX}{config_entry_id}", - media_content_type=f"{MEDIA_PLAYER_PREFIX}library", - thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", - can_play=False, - can_expand=True, - ) + config_entries = hass.config_entries.async_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) + children = [ + BrowseMedia( + title=config_entry.title, + media_class=MediaClass.APP, + media_content_id=f"{MEDIA_PLAYER_PREFIX}{config_entry.entry_id}", + media_content_type=f"{MEDIA_PLAYER_PREFIX}library", + thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", + can_play=False, + can_expand=True, ) + for config_entry in config_entries + ] return BrowseMedia( title="Spotify", media_class=MediaClass.APP, @@ -171,9 +174,15 @@ async def async_browse_media( # Check for config entry specifier, and extract Spotify URI parsed_url = yarl.URL(media_content_id) - if (info := hass.data[DOMAIN].get(parsed_url.host)) is None: + + if ( + parsed_url.host is None + or (entry := hass.config_entries.async_get_entry(parsed_url.host)) is None + or not isinstance(entry.runtime_data, HomeAssistantSpotifyData) + ): raise BrowseError("Invalid Spotify account specified") media_content_id = parsed_url.name + info = entry.runtime_data result = await async_browse_media_internal( hass, diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index fc7a084939a..fe9614374f7 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -22,7 +22,6 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -30,7 +29,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow -from . import HomeAssistantSpotifyData +from . import HomeAssistantSpotifyData, SpotifyConfigEntry from .browse_media import async_browse_media_internal from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES, SPOTIFY_SCOPES from .util import fetch_image_url @@ -70,12 +69,12 @@ SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SpotifyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Spotify based on a config entry.""" spotify = SpotifyMediaPlayer( - hass.data[DOMAIN][entry.entry_id], + entry.runtime_data, entry.data[CONF_ID], entry.title, ) From a43fe714132253732064188a029ebba373286939 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 May 2024 10:54:38 +0200 Subject: [PATCH 122/164] Store runtime data inside the config entry in Forecast Solar (#117033) --- .../components/forecast_solar/__init__.py | 15 +++++++-------- .../components/forecast_solar/diagnostics.py | 10 +++------- homeassistant/components/forecast_solar/energy.py | 8 +++++--- homeassistant/components/forecast_solar/sensor.py | 8 +++++--- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index f4cb1d0a631..7c84436d1e4 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -11,12 +11,13 @@ from .const import ( CONF_DAMPING_EVENING, CONF_DAMPING_MORNING, CONF_MODULES_POWER, - DOMAIN, ) from .coordinator import ForecastSolarDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] + async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old config entry.""" @@ -36,12 +37,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> bool: """Set up Forecast.Solar from a config entry.""" coordinator = ForecastSolarDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -52,11 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index a9bcebdb3cd..cb33ac5dc5a 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -4,15 +4,11 @@ from __future__ import annotations from typing import Any -from forecast_solar import Estimate - from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from . import ForecastSolarConfigEntry TO_REDACT = { CONF_API_KEY, @@ -22,10 +18,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ForecastSolarConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[Estimate] = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": { diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py index f4d03f26299..9031e5c1e1d 100644 --- a/homeassistant/components/forecast_solar/energy.py +++ b/homeassistant/components/forecast_solar/energy.py @@ -4,19 +4,21 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import ForecastSolarDataUpdateCoordinator async def async_get_solar_forecast( hass: HomeAssistant, config_entry_id: str ) -> dict[str, dict[str, float | int]] | None: """Get solar forecast for a config entry ID.""" - if (coordinator := hass.data[DOMAIN].get(config_entry_id)) is None: + if ( + entry := hass.config_entries.async_get_entry(config_entry_id) + ) is None or not isinstance(entry.runtime_data, ForecastSolarDataUpdateCoordinator): return None return { "wh_hours": { timestamp.isoformat(): val - for timestamp, val in coordinator.data.wh_period.items() + for timestamp, val in entry.runtime_data.data.wh_period.items() } } diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 8d35b38765a..c1fa971a89d 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import ForecastSolarConfigEntry from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator @@ -133,10 +133,12 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ForecastSolarConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator: ForecastSolarDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ForecastSolarSensorEntity( From 943799f4d974a62c5bd4f331fc379abcf5cd6ed4 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 25 May 2024 11:00:47 +0200 Subject: [PATCH 123/164] Adjust title of integration sensor (#116954) --- homeassistant/components/integration/manifest.json | 2 +- homeassistant/components/integration/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/integration/manifest.json b/homeassistant/components/integration/manifest.json index 9e5c597bd1a..029d4740c6f 100644 --- a/homeassistant/components/integration/manifest.json +++ b/homeassistant/components/integration/manifest.json @@ -1,6 +1,6 @@ { "domain": "integration", - "name": "Integration - Riemann sum integral", + "name": "Integral", "after_dependencies": ["counter"], "codeowners": ["@dgomes"], "config_flow": true, diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json index 0f5231399b7..ed34b0842d5 100644 --- a/homeassistant/components/integration/strings.json +++ b/homeassistant/components/integration/strings.json @@ -1,5 +1,5 @@ { - "title": "Integration - Riemann sum integral sensor", + "title": "Integral sensor", "config": { "step": { "user": { From 5c60a5ae59999d08818fb9ffdcf059b43b7229eb Mon Sep 17 00:00:00 2001 From: Allister Maguire Date: Sat, 25 May 2024 21:16:51 +1200 Subject: [PATCH 124/164] Bump pyenvisalink version to 4.7 (#118086) --- homeassistant/components/envisalink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index 093ebf77eba..0cf9f165aa2 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/envisalink", "iot_class": "local_push", "loggers": ["pyenvisalink"], - "requirements": ["pyenvisalink==4.6"] + "requirements": ["pyenvisalink==4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 29374c54692..6baa552b0f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1821,7 +1821,7 @@ pyegps==0.2.5 pyenphase==1.20.3 # homeassistant.components.envisalink -pyenvisalink==4.6 +pyenvisalink==4.7 # homeassistant.components.ephember pyephember==0.3.1 From 4da125e27b00c72e698a6069ae6e61b963528cbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 May 2024 23:27:22 -1000 Subject: [PATCH 125/164] Simplify mqtt discovery cooldown calculation (#118095) --- homeassistant/components/mqtt/client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 3e2507ade95..7b43388fe93 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -1257,9 +1257,7 @@ class MQTT: last_discovery = self._mqtt_data.last_discovery last_subscribe = now if self._pending_subscriptions else self._last_subscribe - wait_until = max( - last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN - ) + wait_until = max(last_discovery, last_subscribe) + DISCOVERY_COOLDOWN while now < wait_until: await asyncio.sleep(wait_until - now) now = time.monotonic() @@ -1267,9 +1265,7 @@ class MQTT: last_subscribe = ( now if self._pending_subscriptions else self._last_subscribe ) - wait_until = max( - last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN - ) + wait_until = max(last_discovery, last_subscribe) + DISCOVERY_COOLDOWN def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: From 204cd376cbc226b1525e75009d66809bc43eb5fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 May 2024 23:48:06 -1000 Subject: [PATCH 126/164] Migrate firmata to use async_unload_platforms (#118098) --- homeassistant/components/firmata/__init__.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index 283fd585d35..26fbe596aa8 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -1,6 +1,5 @@ """Support for Arduino-compatible Microcontrollers through Firmata.""" -import asyncio from copy import copy import logging @@ -212,16 +211,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Shutdown and close a Firmata board for a config entry.""" _LOGGER.debug("Closing Firmata board %s", config_entry.data[CONF_NAME]) - - unload_entries = [] - for conf, platform in CONF_PLATFORM_MAP.items(): - if conf in config_entry.data: - unload_entries.append( - hass.config_entries.async_forward_entry_unload(config_entry, platform) - ) - results = [] - if unload_entries: - results = await asyncio.gather(*unload_entries) + results: list[bool] = [] + if platforms := [ + platform + for conf, platform in CONF_PLATFORM_MAP.items() + if conf in config_entry.data + ]: + results.append( + await hass.config_entries.async_unload_platforms(config_entry, platforms) + ) results.append(await hass.data[DOMAIN].pop(config_entry.entry_id).async_reset()) return False not in results From 131c1807c4356d39917f6aedfb147a552b2acb42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 May 2024 23:48:55 -1000 Subject: [PATCH 127/164] Migrate vera to use async_unload_platforms (#118099) --- homeassistant/components/vera/__init__.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index 5340863fa18..722a6b86d4b 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Awaitable import logging from typing import Any @@ -157,16 +156,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload Withings config entry.""" + """Unload vera config entry.""" controller_data: ControllerData = get_controller_data(hass, config_entry) - - tasks: list[Awaitable] = [ - hass.config_entries.async_forward_entry_unload(config_entry, platform) - for platform in get_configured_platforms(controller_data) - ] - tasks.append(hass.async_add_executor_job(controller_data.controller.stop)) - await asyncio.gather(*tasks) - + await asyncio.gather( + *( + hass.config_entries.async_unload_platforms( + config_entry, get_configured_platforms(controller_data) + ), + hass.async_add_executor_job(controller_data.controller.stop), + ) + ) return True From 2954cba65dae3242aa70f4add2c82328d7242910 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 May 2024 23:53:42 -1000 Subject: [PATCH 128/164] Migrate zha to use async_unload_platforms (#118100) --- homeassistant/components/zha/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index de761138ce1..ed74cde47e1 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,6 +1,5 @@ """Support for Zigbee Home Automation devices.""" -import asyncio import contextlib import copy import logging @@ -238,12 +237,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> websocket_api.async_unload_api(hass) # our components don't have unload methods so no need to look at return values - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(config_entry, platform) - for platform in PLATFORMS - ) - ) + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) return True From b58e0331cfb410bdd64ac9d5e39ddc17075eccf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 May 2024 23:54:25 -1000 Subject: [PATCH 129/164] Migrate zwave_js to use async_unload_platforms (#118101) --- homeassistant/components/zwave_js/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index e0b0e3cd370..efd9ab717ad 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Coroutine from contextlib import suppress import logging from typing import Any @@ -958,14 +957,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" client: ZwaveClient = entry.runtime_data[DATA_CLIENT] driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] - - tasks: list[Coroutine] = [ - hass.config_entries.async_forward_entry_unload(entry, platform) + platforms = [ + platform for platform, task in driver_events.platform_setup_tasks.items() if not task.cancel() ] - - unload_ok = all(await asyncio.gather(*tasks)) if tasks else True + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if client.connected and client.driver: await async_disable_server_logging_if_needed(hass, entry, client.driver) From 3f76b865fa506a63ffedf0fe3d027c5bd83f4025 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 May 2024 23:55:36 -1000 Subject: [PATCH 130/164] Switch mqtt to use async_unload_platforms (#118097) --- homeassistant/components/mqtt/__init__.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 1e946421bcf..3391312bdd0 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -522,24 +522,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_client = mqtt_data.client # Unload publish and dump services. - hass.services.async_remove( - DOMAIN, - SERVICE_PUBLISH, - ) - hass.services.async_remove( - DOMAIN, - SERVICE_DUMP, - ) + hass.services.async_remove(DOMAIN, SERVICE_PUBLISH) + hass.services.async_remove(DOMAIN, SERVICE_DUMP) # Stop the discovery await discovery.async_stop(hass) # Unload the platforms - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(entry, component) - for component in mqtt_data.platforms_loaded - ) - ) + await hass.config_entries.async_unload_platforms(entry, mqtt_data.platforms_loaded) mqtt_data.platforms_loaded = set() await asyncio.sleep(0) # Unsubscribe reload dispatchers From e8226a805692bb1752a5bd5990e3b8700013b595 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 25 May 2024 13:17:33 +0300 Subject: [PATCH 131/164] Store Switcher runtime data in config entry (#118054) --- .../components/switcher_kis/__init__.py | 42 +++++++++---------- .../components/switcher_kis/button.py | 4 +- .../components/switcher_kis/climate.py | 4 +- .../components/switcher_kis/const.py | 3 -- .../components/switcher_kis/diagnostics.py | 9 ++-- .../components/switcher_kis/utils.py | 22 +--------- tests/components/switcher_kis/conftest.py | 12 ++++-- tests/components/switcher_kis/test_init.py | 13 ++---- tests/components/switcher_kis/test_sensor.py | 6 +-- 9 files changed, 42 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index abc9091742a..60b3b18b0b0 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -4,15 +4,14 @@ from __future__ import annotations import logging +from aioswitcher.bridge import SwitcherBridge from aioswitcher.device import SwitcherBase from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from .const import DATA_DEVICE, DOMAIN from .coordinator import SwitcherDataUpdateCoordinator -from .utils import async_start_bridge, async_stop_bridge PLATFORMS = [ Platform.BUTTON, @@ -25,20 +24,20 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type SwitcherConfigEntry = ConfigEntry[dict[str, SwitcherDataUpdateCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> bool: """Set up Switcher from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][DATA_DEVICE] = {} @callback def on_device_data_callback(device: SwitcherBase) -> None: """Use as a callback for device data.""" + coordinators = entry.runtime_data + # Existing device update device data - if device.device_id in hass.data[DOMAIN][DATA_DEVICE]: - coordinator: SwitcherDataUpdateCoordinator = hass.data[DOMAIN][DATA_DEVICE][ - device.device_id - ] + if coordinator := coordinators.get(device.device_id): coordinator.async_set_updated_data(device) return @@ -52,18 +51,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.device_type.hex_rep, ) - coordinator = hass.data[DOMAIN][DATA_DEVICE][device.device_id] = ( - SwitcherDataUpdateCoordinator(hass, entry, device) - ) + coordinator = SwitcherDataUpdateCoordinator(hass, entry, device) coordinator.async_setup() + coordinators[device.device_id] = coordinator # Must be ready before dispatcher is called await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_start_bridge(hass, on_device_data_callback) + entry.runtime_data = {} + bridge = SwitcherBridge(on_device_data_callback) + await bridge.start() - async def stop_bridge(event: Event) -> None: - await async_stop_bridge(hass) + async def stop_bridge(event: Event | None = None) -> None: + await bridge.stop() + + entry.async_on_unload(stop_bridge) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) @@ -72,12 +74,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> bool: """Unload a config entry.""" - await async_stop_bridge(hass) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(DATA_DEVICE) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index b787043f86c..9454dcabc49 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -15,7 +15,6 @@ from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.device import DeviceCategory from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -25,6 +24,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .utils import get_breeze_remote_manager @@ -78,7 +78,7 @@ THERMOSTAT_BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SwitcherConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Switcher button from config entry.""" diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index efcb9c81f0a..9797873c73b 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -25,7 +25,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -35,6 +34,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .utils import get_breeze_remote_manager @@ -61,7 +61,7 @@ HA_TO_DEVICE_FAN = {value: key for key, value in DEVICE_FAN_TO_HA.items()} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SwitcherConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Switcher climate from config entry.""" diff --git a/homeassistant/components/switcher_kis/const.py b/homeassistant/components/switcher_kis/const.py index 76eb2a3e497..9edc69e4946 100644 --- a/homeassistant/components/switcher_kis/const.py +++ b/homeassistant/components/switcher_kis/const.py @@ -2,9 +2,6 @@ DOMAIN = "switcher_kis" -DATA_BRIDGE = "bridge" -DATA_DEVICE = "device" - DISCOVERY_TIME_SEC = 12 SIGNAL_DEVICE_ADD = "switcher_device_add" diff --git a/homeassistant/components/switcher_kis/diagnostics.py b/homeassistant/components/switcher_kis/diagnostics.py index 441f45198a2..a81e3e25bb9 100644 --- a/homeassistant/components/switcher_kis/diagnostics.py +++ b/homeassistant/components/switcher_kis/diagnostics.py @@ -6,24 +6,23 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DATA_DEVICE, DOMAIN +from . import SwitcherConfigEntry TO_REDACT = {"device_id", "device_key", "ip_address", "mac_address"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SwitcherConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - devices = hass.data[DOMAIN][DATA_DEVICE] + coordinators = entry.runtime_data return async_redact_data( { "entry": entry.as_dict(), - "devices": [asdict(devices[d].data) for d in devices], + "devices": [asdict(coordinators[d].data) for d in coordinators], }, TO_REDACT, ) diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index 79ac565a737..ad23d51e44d 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable import logging -from typing import Any from aioswitcher.api.remotes import SwitcherBreezeRemoteManager from aioswitcher.bridge import SwitcherBase, SwitcherBridge @@ -13,29 +11,11 @@ from aioswitcher.bridge import SwitcherBase, SwitcherBridge from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton -from .const import DATA_BRIDGE, DISCOVERY_TIME_SEC, DOMAIN +from .const import DISCOVERY_TIME_SEC _LOGGER = logging.getLogger(__name__) -async def async_start_bridge( - hass: HomeAssistant, on_device_callback: Callable[[SwitcherBase], Any] -) -> None: - """Start switcher UDP bridge.""" - bridge = hass.data[DOMAIN][DATA_BRIDGE] = SwitcherBridge(on_device_callback) - _LOGGER.debug("Starting Switcher bridge") - await bridge.start() - - -async def async_stop_bridge(hass: HomeAssistant) -> None: - """Stop switcher UDP bridge.""" - bridge: SwitcherBridge = hass.data[DOMAIN].get(DATA_BRIDGE) - if bridge is not None: - _LOGGER.debug("Stopping Switcher bridge") - await bridge.stop() - hass.data[DOMAIN].pop(DATA_BRIDGE) - - async def async_has_devices(hass: HomeAssistant) -> bool: """Discover Switcher devices.""" _LOGGER.debug("Starting discovery") diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 5f04df7dc66..eb3b92120e1 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -18,9 +18,15 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture def mock_bridge(request): """Return a mocked SwitcherBridge.""" - with patch( - "homeassistant.components.switcher_kis.utils.SwitcherBridge", autospec=True - ) as bridge_mock: + with ( + patch( + "homeassistant.components.switcher_kis.SwitcherBridge", autospec=True + ) as bridge_mock, + patch( + "homeassistant.components.switcher_kis.utils.SwitcherBridge", + new=bridge_mock, + ), + ): bridge = bridge_mock.return_value bridge.devices = [] diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 70eb518820c..14217a7e044 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -4,11 +4,7 @@ from datetime import timedelta import pytest -from homeassistant.components.switcher_kis.const import ( - DATA_DEVICE, - DOMAIN, - MAX_UPDATE_INTERVAL_SEC, -) +from homeassistant.components.switcher_kis.const import MAX_UPDATE_INTERVAL_SEC from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -24,15 +20,14 @@ async def test_update_fail( hass: HomeAssistant, mock_bridge, caplog: pytest.LogCaptureFixture ) -> None: """Test entities state unavailable when updates fail..""" - await init_integration(hass) + entry = await init_integration(hass) assert mock_bridge mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) await hass.async_block_till_done() assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 - assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 + assert len(entry.runtime_data) == 2 async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) @@ -77,11 +72,9 @@ async def test_entry_unload(hass: HomeAssistant, mock_bridge) -> None: assert entry.state is ConfigEntryState.LOADED assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED assert mock_bridge.is_running is False - assert len(hass.data[DOMAIN]) == 0 diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py index f61cdd5a010..bfe1b2c84dd 100644 --- a/tests/components/switcher_kis/test_sensor.py +++ b/tests/components/switcher_kis/test_sensor.py @@ -2,7 +2,6 @@ import pytest -from homeassistant.components.switcher_kis.const import DATA_DEVICE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify @@ -32,12 +31,11 @@ DEVICE_SENSORS_TUPLE = ( @pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None: """Test sensor platform.""" - await init_integration(hass) + entry = await init_integration(hass) assert mock_bridge assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 - assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 + assert len(entry.runtime_data) == 2 for device, sensors in DEVICE_SENSORS_TUPLE: for sensor, field in sensors: From de275878c43d4c60b53f40d1cd647cfef8edc9a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 May 2024 00:32:15 -1000 Subject: [PATCH 132/164] Small speed up to mqtt _async_queue_subscriptions (#118094) --- homeassistant/components/mqtt/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 7b43388fe93..b3fde3f8320 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -452,7 +452,7 @@ class MQTT: self._should_reconnect: bool = True self._available_future: asyncio.Future[bool] | None = None - self._max_qos: dict[str, int] = {} # topic, max qos + self._max_qos: defaultdict[str, int] = defaultdict(int) # topic, max qos self._pending_subscriptions: dict[str, int] = {} # topic, qos self._unsubscribe_debouncer = EnsureJobAfterCooldown( UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes @@ -820,8 +820,8 @@ class MQTT: """Queue requested subscriptions.""" for subscription in subscriptions: topic, qos = subscription - max_qos = max(qos, self._max_qos.setdefault(topic, qos)) - self._max_qos[topic] = max_qos + if (max_qos := self._max_qos[topic]) < qos: + self._max_qos[topic] = (max_qos := qos) self._pending_subscriptions[topic] = max_qos # Cancel any pending unsubscribe since we are subscribing now if topic in self._pending_unsubscribes: From 543d47d7f70181f8a8eca4f8e564c2780df9b676 Mon Sep 17 00:00:00 2001 From: nopoz Date: Sat, 25 May 2024 03:33:39 -0700 Subject: [PATCH 133/164] Allow Meraki API v2 or v2.1 (#115828) --- homeassistant/components/meraki/device_tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 9f0f4cd4545..a6eefe7345f 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_VALIDATOR = "validator" CONF_SECRET = "secret" URL = "/api/meraki" -VERSION = "2.0" +ACCEPTED_VERSIONS = ["2.0", "2.1"] _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ class MerakiView(HomeAssistantView): if data["secret"] != self.secret: _LOGGER.error("Invalid Secret received from Meraki") return self.json_message("Invalid secret", HTTPStatus.UNPROCESSABLE_ENTITY) - if data["version"] != VERSION: + if data["version"] not in ACCEPTED_VERSIONS: _LOGGER.error("Invalid API version: %s", data["version"]) return self.json_message("Invalid version", HTTPStatus.UNPROCESSABLE_ENTITY) _LOGGER.debug("Valid Secret") From 6fc6d109c9b8284f53489be716a879d767867c42 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 12:34:44 +0200 Subject: [PATCH 134/164] Freeze and fix plaato CI tests (#118103) --- tests/components/plaato/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/plaato/__init__.py b/tests/components/plaato/__init__.py index 6c66478eba1..5a2b2a68d44 100644 --- a/tests/components/plaato/__init__.py +++ b/tests/components/plaato/__init__.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun import freeze_time from pyplaato.models.airlock import PlaatoAirlock from pyplaato.models.device import PlaatoDeviceType from pyplaato.models.keg import PlaatoKeg @@ -23,6 +24,7 @@ AIRLOCK_DATA = {} KEG_DATA = {} +@freeze_time("2024-05-24 12:00:00", tz_offset=0) async def init_integration( hass: HomeAssistant, device_type: PlaatoDeviceType ) -> MockConfigEntry: From 10efb2017befb0f39df31ead1355e71ca52ac677 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 May 2024 12:55:40 +0200 Subject: [PATCH 135/164] Use PEP 695 type alias for ConfigEntry type in Spotify (#118106) Use PEP 695 type alias for ConfigEntry type --- homeassistant/components/spotify/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 9bf43609855..632871ba36e 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -30,8 +30,6 @@ from .util import ( PLATFORMS = [Platform.MEDIA_PLAYER] -SpotifyConfigEntry = ConfigEntry["HomeAssistantSpotifyData"] - __all__ = [ "async_browse_media", "DOMAIN", @@ -51,6 +49,9 @@ class HomeAssistantSpotifyData: session: OAuth2Session +type SpotifyConfigEntry = ConfigEntry[HomeAssistantSpotifyData] + + async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: """Set up Spotify from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) From ec76f34ba519f3920f59d99c6125f74590443744 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 25 May 2024 21:29:27 +1000 Subject: [PATCH 136/164] Add device tracker platform to Teslemetry (#117341) --- .../components/teslemetry/__init__.py | 1 + .../components/teslemetry/device_tracker.py | 85 +++++++++++++++ .../components/teslemetry/icons.json | 9 ++ .../components/teslemetry/strings.json | 8 ++ .../snapshots/test_device_tracker.ambr | 101 ++++++++++++++++++ .../teslemetry/test_device_tracker.py | 33 ++++++ 6 files changed, 237 insertions(+) create mode 100644 homeassistant/components/teslemetry/device_tracker.py create mode 100644 tests/components/teslemetry/snapshots/test_device_tracker.ambr create mode 100644 tests/components/teslemetry/test_device_tracker.py diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index a425a26b6da..af2276dbcda 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -30,6 +30,7 @@ PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py new file mode 100644 index 00000000000..afd947ab3b3 --- /dev/null +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -0,0 +1,85 @@ +"""Device tracker platform for Teslemetry integration.""" + +from __future__ import annotations + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry device tracker platform from a config entry.""" + + async_add_entities( + klass(vehicle) + for klass in ( + TeslemetryDeviceTrackerLocationEntity, + TeslemetryDeviceTrackerRouteEntity, + ) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): + """Base class for Teslemetry tracker entities.""" + + lat_key: str + lon_key: str + + def __init__( + self, + vehicle: TeslemetryVehicleData, + ) -> None: + """Initialize the device tracker.""" + super().__init__(vehicle, self.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the device tracker.""" + + self._attr_available = ( + self.get(self.lat_key, False) is not None + and self.get(self.lon_key, False) is not None + ) + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.get(self.lat_key) + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.get(self.lon_key) + + @property + def source_type(self) -> SourceType: + """Return the source type of the device tracker.""" + return SourceType.GPS + + +class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity): + """Vehicle location device tracker class.""" + + key = "location" + lat_key = "drive_state_latitude" + lon_key = "drive_state_longitude" + + +class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): + """Vehicle navigation device tracker class.""" + + key = "route" + lat_key = "drive_state_active_route_latitude" + lon_key = "drive_state_active_route_longitude" + + @property + def location_name(self) -> str | None: + """Return a location name for the current location of the device.""" + return self.get("drive_state_active_route_destination") diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 0236bc41c23..3224fee603b 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -109,6 +109,7 @@ "off": "mdi:car-seat" } }, + "components_customer_preferred_export_rule": { "default": "mdi:transmission-tower", "state": { @@ -126,6 +127,14 @@ } } }, + "device_tracker": { + "location": { + "default": "mdi:map-marker" + }, + "route": { + "default": "mdi:routes" + } + }, "cover": { "charge_state_charge_port_door_open": { "default": "mdi:ev-plug-ccs2" diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 322a27929e5..e41fbbd4507 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -111,6 +111,14 @@ } } }, + "device_tracker": { + "location": { + "name": "Location" + }, + "route": { + "name": "Route" + } + }, "lock": { "charge_state_charge_port_latch": { "name": "Charge cable lock" diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..369a3e3a2b9 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_device_tracker[device_tracker.test_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'VINVINVIN-location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.test_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Location', + 'gps_accuracy': 0, + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[device_tracker.test_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Route', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'route', + 'unique_id': 'VINVINVIN-route', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.test_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Route', + 'gps_accuracy': 0, + 'latitude': 30.2226265, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py new file mode 100644 index 00000000000..55deaefdab5 --- /dev/null +++ b/tests/components/teslemetry/test_device_tracker.py @@ -0,0 +1,33 @@ +"""Test the Teslemetry device tracker platform.""" + +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform + + +async def test_device_tracker( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the device tracker entities are correct.""" + + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_device_tracker_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the device tracker entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.DEVICE_TRACKER]) + state = hass.states.get("device_tracker.test_location") + assert state.state == STATE_UNKNOWN From a89dcbc78b210035aa83919f03a0677aa6995c35 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 May 2024 13:48:58 +0200 Subject: [PATCH 137/164] Use PEP 695 type alias for ConfigEntry type in Forecast Solar (#118107) --- homeassistant/components/forecast_solar/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 7c84436d1e4..00be13f1235 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -16,7 +16,7 @@ from .coordinator import ForecastSolarDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] +type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: From 8fbe39f2a7ab4d0f9f723008a0f14829ed0f7fa3 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Sat, 25 May 2024 07:50:15 -0400 Subject: [PATCH 138/164] Improve nws tests by centralizing and removing unneeded `patch`ing (#118052) --- tests/components/nws/conftest.py | 15 ++++- tests/components/nws/test_weather.py | 93 +++++++++++++--------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/tests/components/nws/conftest.py b/tests/components/nws/conftest.py index 48401fe87ba..65276a1a115 100644 --- a/tests/components/nws/conftest.py +++ b/tests/components/nws/conftest.py @@ -11,8 +11,12 @@ from .const import DEFAULT_FORECAST, DEFAULT_OBSERVATION @pytest.fixture def mock_simple_nws(): """Mock pynws SimpleNWS with default values.""" - - with patch("homeassistant.components.nws.SimpleNWS") as mock_nws: + # set RETRY_STOP and RETRY_INTERVAL to avoid retries inside pynws in tests + with ( + patch("homeassistant.components.nws.SimpleNWS") as mock_nws, + patch("homeassistant.components.nws.coordinator.RETRY_STOP", 0), + patch("homeassistant.components.nws.coordinator.RETRY_INTERVAL", 0), + ): instance = mock_nws.return_value instance.set_station = AsyncMock(return_value=None) instance.update_observation = AsyncMock(return_value=None) @@ -29,7 +33,12 @@ def mock_simple_nws(): @pytest.fixture def mock_simple_nws_times_out(): """Mock pynws SimpleNWS that times out.""" - with patch("homeassistant.components.nws.SimpleNWS") as mock_nws: + # set RETRY_STOP and RETRY_INTERVAL to avoid retries inside pynws in tests + with ( + patch("homeassistant.components.nws.SimpleNWS") as mock_nws, + patch("homeassistant.components.nws.coordinator.RETRY_STOP", 0), + patch("homeassistant.components.nws.coordinator.RETRY_INTERVAL", 0), + ): instance = mock_nws.return_value instance.set_station = AsyncMock(side_effect=asyncio.TimeoutError) instance.update_observation = AsyncMock(side_effect=asyncio.TimeoutError) diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 32cbfe4befe..5406636c324 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -1,7 +1,6 @@ """Tests for the NWS weather component.""" from datetime import timedelta -from unittest.mock import patch import aiohttp from freezegun.api import FrozenDateTimeFactory @@ -24,7 +23,6 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import ( @@ -127,47 +125,43 @@ async def test_data_caching_error_observation( caplog, ) -> None: """Test caching of data with errors.""" - with ( - patch("homeassistant.components.nws.coordinator.RETRY_STOP", 0), - patch("homeassistant.components.nws.coordinator.RETRY_INTERVAL", 0), - ): - instance = mock_simple_nws.return_value + instance = mock_simple_nws.return_value - entry = MockConfigEntry( - domain=nws.DOMAIN, - data=NWS_CONFIG, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - state = hass.states.get("weather.abc") - assert state.state == "sunny" + state = hass.states.get("weather.abc") + assert state.state == "sunny" - # data is still valid even when update fails - instance.update_observation.side_effect = NwsNoDataError("Test") + # data is still valid even when update fails + instance.update_observation.side_effect = NwsNoDataError("Test") - freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=100)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=100)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state = hass.states.get("weather.abc") - assert state.state == "sunny" + state = hass.states.get("weather.abc") + assert state.state == "sunny" - assert ( - "NWS observation update failed, but data still valid. Last success: " - in caplog.text - ) + assert ( + "NWS observation update failed, but data still valid. Last success: " + in caplog.text + ) - # data is no longer valid after OBSERVATION_VALID_TIME - freezer.tick(OBSERVATION_VALID_TIME + timedelta(seconds=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + # data is no longer valid after OBSERVATION_VALID_TIME + freezer.tick(OBSERVATION_VALID_TIME + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state = hass.states.get("weather.abc") - assert state.state == STATE_UNAVAILABLE + state = hass.states.get("weather.abc") + assert state.state == STATE_UNAVAILABLE - assert "Error fetching NWS observation station ABC data: Test" in caplog.text + assert "Error fetching NWS observation station ABC data: Test" in caplog.text async def test_no_data_error_observation( @@ -302,26 +296,23 @@ async def test_error_observation( hass: HomeAssistant, mock_simple_nws, no_sensor ) -> None: """Test error during update observation.""" - utc_time = dt_util.utcnow() - with patch("homeassistant.components.nws.coordinator.utcnow") as mock_utc: - mock_utc.return_value = utc_time - instance = mock_simple_nws.return_value - # first update fails - instance.update_observation.side_effect = aiohttp.ClientError + instance = mock_simple_nws.return_value + # first update fails + instance.update_observation.side_effect = aiohttp.ClientError - entry = MockConfigEntry( - domain=nws.DOMAIN, - data=NWS_CONFIG, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - instance.update_observation.assert_called_once() + instance.update_observation.assert_called_once() - state = hass.states.get("weather.abc") - assert state - assert state.state == STATE_UNAVAILABLE + state = hass.states.get("weather.abc") + assert state + assert state.state == STATE_UNAVAILABLE async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: From 0182bfcc81900dacdb7c3ac8daee19f6a08d1d39 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 25 May 2024 04:52:20 -0700 Subject: [PATCH 139/164] Google Generative AI: 100% test coverage for conversation (#118112) 100% coverage for conversation --- .../conversation.py | 4 +- .../snapshots/test_conversation.ambr | 110 ++++++++++++++++++ .../test_conversation.py | 105 ++++++++++++++++- 3 files changed, 214 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 627b28d0966..8a6a761d549 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, Literal import google.ai.generativelanguage as glm -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import GoogleAPICallError import google.generativeai as genai import google.generativeai.types as genai_types import voluptuous as vol @@ -258,7 +258,7 @@ class GoogleGenerativeAIConversationEntity( try: chat_response = await chat.send_message_async(chat_request) except ( - ClientError, + GoogleAPICallError, ValueError, genai_types.BlockedPromptException, genai_types.StopCandidateException, diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index e1f8141a692..6d37c1d1823 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -1,4 +1,114 @@ # serializer version: 1 +# name: test_chat_history + list([ + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-flash-latest', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + dict({ + 'parts': ''' + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + ''', + 'role': 'user', + }), + dict({ + 'parts': 'Ok', + 'role': 'model', + }), + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + '1st user request', + ), + dict({ + }), + ), + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-flash-latest', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + dict({ + 'parts': ''' + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + ''', + 'role': 'user', + }), + dict({ + 'parts': 'Ok', + 'role': 'model', + }), + dict({ + 'parts': '1st user request', + 'role': 'user', + }), + dict({ + 'parts': '1st model response', + 'role': 'model', + }), + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + '2nd user request', + ), + dict({ + }), + ), + ]) +# --- # name: test_default_prompt[config_entry_options0-None] list([ tuple( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index af7aebace35..b31d9442a43 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import GoogleAPICallError +import google.generativeai.types as genai_types import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -150,6 +151,57 @@ async def test_default_prompt( assert mock_get_tools.called == (CONF_LLM_HASS_API in config_entry_options) +async def test_chat_history( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test that the agent keeps track of the chat history.""" + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + mock_part = MagicMock() + mock_part.function_call = None + chat_response.parts = [mock_part] + chat_response.text = "1st model response" + mock_chat.history = [ + {"role": "user", "parts": "prompt"}, + {"role": "model", "parts": "Ok"}, + {"role": "user", "parts": "1st user request"}, + {"role": "model", "parts": "1st model response"}, + ] + result = await conversation.async_converse( + hass, + "1st user request", + None, + Context(), + agent_id=mock_config_entry.entry_id, + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.as_dict()["speech"]["plain"]["speech"] + == "1st model response" + ) + chat_response.text = "2nd model response" + result = await conversation.async_converse( + hass, + "2nd user request", + result.conversation_id, + Context(), + agent_id=mock_config_entry.entry_id, + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.as_dict()["speech"]["plain"]["speech"] + == "2nd model response" + ) + + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + + @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI.async_get_tools" ) @@ -325,7 +377,7 @@ async def test_error_handling( with patch("google.generativeai.GenerativeModel") as mock_model: mock_chat = AsyncMock() mock_model.return_value.start_chat.return_value = mock_chat - mock_chat.send_message_async.side_effect = ClientError("some error") + mock_chat.send_message_async.side_effect = GoogleAPICallError("some error") result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) @@ -340,7 +392,28 @@ async def test_error_handling( async def test_blocked_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: - """Test response was blocked.""" + """Test blocked response.""" + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + mock_chat.send_message_async.side_effect = genai_types.StopCandidateException( + "finish_reason: SAFETY\n" + ) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "The message got blocked by your safety settings" + ) + + +async def test_empty_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test empty response.""" with patch("google.generativeai.GenerativeModel") as mock_model: mock_chat = AsyncMock() mock_model.return_value.start_chat.return_value = mock_chat @@ -358,6 +431,32 @@ async def test_blocked_response( ) +async def test_invalid_llm_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test handling of invalid llm api.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, + ) + + result = await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "Error preparing LLM API: API invalid_llm_api not found" + ) + + async def test_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: From 73f9234107e461dac4612bfdfa9116ebb725895d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 May 2024 13:52:28 +0200 Subject: [PATCH 140/164] Remove deprecated services from AVM Fritz!Box Tools (#118108) --- homeassistant/components/fritz/button.py | 4 +-- homeassistant/components/fritz/const.py | 3 -- homeassistant/components/fritz/coordinator.py | 27 ---------------- homeassistant/components/fritz/services.py | 12 +------ homeassistant/components/fritz/services.yaml | 28 ---------------- homeassistant/components/fritz/strings.json | 32 +------------------ 6 files changed, 4 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index a0cbd54eaac..263521d23f4 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Final +from typing import Any, Final from homeassistant.components.button import ( ButtonDeviceClass, @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) class FritzButtonDescription(ButtonEntityDescription): """Class to describe a Button entity.""" - press_action: Callable + press_action: Callable[[AvmWrapper], Any] BUTTONS: Final = [ diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 3794a83dd7f..9a266507c25 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -57,9 +57,6 @@ ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured" ERROR_UNKNOWN = "unknown_error" FRITZ_SERVICES = "fritz_services" -SERVICE_REBOOT = "reboot" -SERVICE_RECONNECT = "reconnect" -SERVICE_CLEANUP = "cleanup" SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" SWITCH_TYPE_DEFLECTION = "CallDeflection" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 7256085b93a..299679e642a 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -46,9 +46,6 @@ from .const import ( DEFAULT_USERNAME, DOMAIN, FRITZ_EXCEPTIONS, - SERVICE_CLEANUP, - SERVICE_REBOOT, - SERVICE_RECONNECT, SERVICE_SET_GUEST_WIFI_PW, MeshRoles, ) @@ -730,30 +727,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ) try: - if service_call.service == SERVICE_REBOOT: - _LOGGER.warning( - 'Service "fritz.reboot" is deprecated, please use the corresponding' - " button entity instead" - ) - await self.async_trigger_reboot() - return - - if service_call.service == SERVICE_RECONNECT: - _LOGGER.warning( - 'Service "fritz.reconnect" is deprecated, please use the' - " corresponding button entity instead" - ) - await self.async_trigger_reconnect() - return - - if service_call.service == SERVICE_CLEANUP: - _LOGGER.warning( - 'Service "fritz.cleanup" is deprecated, please use the' - " corresponding button entity instead" - ) - await self.async_trigger_cleanup(config_entry) - return - if service_call.service == SERVICE_SET_GUEST_WIFI_PW: await self.async_trigger_set_guest_password( service_call.data.get("password"), diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index bd1f3136b01..bace7480ba5 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -11,14 +11,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service import async_extract_config_entry_ids -from .const import ( - DOMAIN, - FRITZ_SERVICES, - SERVICE_CLEANUP, - SERVICE_REBOOT, - SERVICE_RECONNECT, - SERVICE_SET_GUEST_WIFI_PW, -) +from .const import DOMAIN, FRITZ_SERVICES, SERVICE_SET_GUEST_WIFI_PW from .coordinator import AvmWrapper _LOGGER = logging.getLogger(__name__) @@ -32,9 +25,6 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( ) SERVICE_LIST: list[tuple[str, vol.Schema | None]] = [ - (SERVICE_CLEANUP, None), - (SERVICE_REBOOT, None), - (SERVICE_RECONNECT, None), (SERVICE_SET_GUEST_WIFI_PW, SERVICE_SCHEMA_SET_GUEST_WIFI_PW), ] diff --git a/homeassistant/components/fritz/services.yaml b/homeassistant/components/fritz/services.yaml index b9828280aa2..0ac7ca20c3d 100644 --- a/homeassistant/components/fritz/services.yaml +++ b/homeassistant/components/fritz/services.yaml @@ -1,31 +1,3 @@ -reconnect: - fields: - device_id: - required: true - selector: - device: - integration: fritz - entity: - device_class: connectivity -reboot: - fields: - device_id: - required: true - selector: - device: - integration: fritz - entity: - device_class: connectivity - -cleanup: - fields: - device_id: - required: true - selector: - device: - integration: fritz - entity: - device_class: connectivity set_guest_wifi_password: fields: device_id: diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 30603ca9032..eb47f76f27e 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -144,42 +144,12 @@ } }, "services": { - "reconnect": { - "name": "[%key:component::fritz::entity::button::reconnect::name%]", - "description": "Reconnects your FRITZ!Box internet connection.", - "fields": { - "device_id": { - "name": "Fritz!Box Device", - "description": "Select the Fritz!Box to reconnect." - } - } - }, - "reboot": { - "name": "Reboot", - "description": "Reboots your FRITZ!Box.", - "fields": { - "device_id": { - "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", - "description": "Select the Fritz!Box to reboot." - } - } - }, - "cleanup": { - "name": "Remove stale device tracker entities", - "description": "Remove FRITZ!Box stale device_tracker entities.", - "fields": { - "device_id": { - "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", - "description": "Select the Fritz!Box to check." - } - } - }, "set_guest_wifi_password": { "name": "Set guest Wi-Fi password", "description": "Sets a new password for the guest Wi-Fi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters.", "fields": { "device_id": { - "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", + "name": "Fritz!Box Device", "description": "Select the Fritz!Box to configure." }, "password": { From 344bb568f4e9af2c2586e876e09060d34f2671b0 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 25 May 2024 14:01:24 +0200 Subject: [PATCH 141/164] Add diagnostics support for Fronius (#117845) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/fronius/diagnostics.py | 46 +++ script/hassfest/manifest.py | 1 - tests/components/fronius/__init__.py | 1 + .../fronius/snapshots/test_diagnostics.ambr | 370 ++++++++++++++++++ tests/components/fronius/test_diagnostics.py | 31 ++ 5 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/fronius/diagnostics.py create mode 100644 tests/components/fronius/snapshots/test_diagnostics.ambr create mode 100644 tests/components/fronius/test_diagnostics.py diff --git a/homeassistant/components/fronius/diagnostics.py b/homeassistant/components/fronius/diagnostics.py new file mode 100644 index 00000000000..17737ba31f8 --- /dev/null +++ b/homeassistant/components/fronius/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for Fronius.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from . import FroniusConfigEntry + +TO_REDACT = {"unique_id", "unique_identifier", "serial"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: FroniusConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + diag: dict[str, Any] = {} + solar_net = config_entry.runtime_data + fronius = solar_net.fronius + + diag["config_entry"] = config_entry.as_dict() + diag["inverter_info"] = await fronius.inverter_info() + + diag["coordinators"] = {"inverters": {}} + for inv in solar_net.inverter_coordinators: + diag["coordinators"]["inverters"] |= inv.data + + diag["coordinators"]["logger"] = ( + solar_net.logger_coordinator.data if solar_net.logger_coordinator else None + ) + diag["coordinators"]["meter"] = ( + solar_net.meter_coordinator.data if solar_net.meter_coordinator else None + ) + diag["coordinators"]["ohmpilot"] = ( + solar_net.ohmpilot_coordinator.data if solar_net.ohmpilot_coordinator else None + ) + diag["coordinators"]["power_flow"] = ( + solar_net.power_flow_coordinator.data + if solar_net.power_flow_coordinator + else None + ) + diag["coordinators"]["storage"] = ( + solar_net.storage_coordinator.data if solar_net.storage_coordinator else None + ) + + return async_redact_data(diag, TO_REDACT) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 54ae65e6727..e92ec00b117 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -117,7 +117,6 @@ NO_IOT_CLASS = [ # https://github.com/home-assistant/developers.home-assistant/pull/1512 NO_DIAGNOSTICS = [ "dlna_dms", - "fronius", "gdacs", "geonetnz_quakes", "google_assistant_sdk", diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index f1630d6cd7e..6cefae734a0 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -25,6 +25,7 @@ async def setup_fronius_integration( """Create the Fronius integration.""" entry = MockConfigEntry( domain=DOMAIN, + entry_id="f1e2b9837e8adaed6fa682acaa216fd8", unique_id=unique_id, # has to match mocked logger unique_id data={ CONF_HOST: MOCK_HOST, diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f23d63a58e3 --- /dev/null +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -0,0 +1,370 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'http://fronius', + 'is_logger': True, + }), + 'disabled_by': None, + 'domain': 'fronius', + 'entry_id': 'f1e2b9837e8adaed6fa682acaa216fd8', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'coordinators': dict({ + 'inverters': dict({ + '1': dict({ + 'current_ac': dict({ + 'unit': 'A', + 'value': 5.19, + }), + 'current_dc': dict({ + 'unit': 'A', + 'value': 2.19, + }), + 'energy_day': dict({ + 'unit': 'Wh', + 'value': 1113, + }), + 'energy_total': dict({ + 'unit': 'Wh', + 'value': 44188000, + }), + 'energy_year': dict({ + 'unit': 'Wh', + 'value': 25508798, + }), + 'error_code': dict({ + 'value': 0, + }), + 'frequency_ac': dict({ + 'unit': 'Hz', + 'value': 49.94, + }), + 'led_color': dict({ + 'value': 2, + }), + 'led_state': dict({ + 'value': 0, + }), + 'power_ac': dict({ + 'unit': 'W', + 'value': 1190, + }), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'status_code': dict({ + 'value': 7, + }), + 'timestamp': dict({ + 'value': '2021-10-07T10:01:17+02:00', + }), + 'voltage_ac': dict({ + 'unit': 'V', + 'value': 227.9, + }), + 'voltage_dc': dict({ + 'unit': 'V', + 'value': 518, + }), + }), + }), + 'logger': dict({ + 'system': dict({ + 'cash_factor': dict({ + 'unit': 'EUR/kWh', + 'value': 0.07800000160932541, + }), + 'co2_factor': dict({ + 'unit': 'kg/kWh', + 'value': 0.5299999713897705, + }), + 'delivery_factor': dict({ + 'unit': 'EUR/kWh', + 'value': 0.15000000596046448, + }), + 'hardware_platform': dict({ + 'value': 'wilma', + }), + 'hardware_version': dict({ + 'value': '2.4E', + }), + 'product_type': dict({ + 'value': 'fronius-datamanager-card', + }), + 'software_version': dict({ + 'value': '3.18.7-1', + }), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'time_zone': dict({ + 'value': 'CEST', + }), + 'time_zone_location': dict({ + 'value': 'Vienna', + }), + 'timestamp': dict({ + 'value': '2021-10-06T23:56:32+02:00', + }), + 'unique_identifier': '**REDACTED**', + 'utc_offset': dict({ + 'value': 7200, + }), + }), + }), + 'meter': dict({ + '0': dict({ + 'current_ac_phase_1': dict({ + 'unit': 'A', + 'value': 7.755, + }), + 'current_ac_phase_2': dict({ + 'unit': 'A', + 'value': 6.68, + }), + 'current_ac_phase_3': dict({ + 'unit': 'A', + 'value': 10.102, + }), + 'enable': dict({ + 'value': 1, + }), + 'energy_reactive_ac_consumed': dict({ + 'unit': 'VArh', + 'value': 59960790, + }), + 'energy_reactive_ac_produced': dict({ + 'unit': 'VArh', + 'value': 723160, + }), + 'energy_real_ac_minus': dict({ + 'unit': 'Wh', + 'value': 35623065, + }), + 'energy_real_ac_plus': dict({ + 'unit': 'Wh', + 'value': 15303334, + }), + 'energy_real_consumed': dict({ + 'unit': 'Wh', + 'value': 15303334, + }), + 'energy_real_produced': dict({ + 'unit': 'Wh', + 'value': 35623065, + }), + 'frequency_phase_average': dict({ + 'unit': 'Hz', + 'value': 50, + }), + 'manufacturer': dict({ + 'value': 'Fronius', + }), + 'meter_location': dict({ + 'value': 0, + }), + 'model': dict({ + 'value': 'Smart Meter 63A', + }), + 'power_apparent': dict({ + 'unit': 'VA', + 'value': 5592.57, + }), + 'power_apparent_phase_1': dict({ + 'unit': 'VA', + 'value': 1772.793, + }), + 'power_apparent_phase_2': dict({ + 'unit': 'VA', + 'value': 1527.048, + }), + 'power_apparent_phase_3': dict({ + 'unit': 'VA', + 'value': 2333.562, + }), + 'power_factor': dict({ + 'value': 1, + }), + 'power_factor_phase_1': dict({ + 'value': -0.99, + }), + 'power_factor_phase_2': dict({ + 'value': -0.99, + }), + 'power_factor_phase_3': dict({ + 'value': 0.99, + }), + 'power_reactive': dict({ + 'unit': 'VAr', + 'value': 2.87, + }), + 'power_reactive_phase_1': dict({ + 'unit': 'VAr', + 'value': 51.48, + }), + 'power_reactive_phase_2': dict({ + 'unit': 'VAr', + 'value': 115.63, + }), + 'power_reactive_phase_3': dict({ + 'unit': 'VAr', + 'value': -164.24, + }), + 'power_real': dict({ + 'unit': 'W', + 'value': 5592.57, + }), + 'power_real_phase_1': dict({ + 'unit': 'W', + 'value': 1765.55, + }), + 'power_real_phase_2': dict({ + 'unit': 'W', + 'value': 1515.8, + }), + 'power_real_phase_3': dict({ + 'unit': 'W', + 'value': 2311.22, + }), + 'serial': '**REDACTED**', + 'visible': dict({ + 'value': 1, + }), + 'voltage_ac_phase_1': dict({ + 'unit': 'V', + 'value': 228.6, + }), + 'voltage_ac_phase_2': dict({ + 'unit': 'V', + 'value': 228.6, + }), + 'voltage_ac_phase_3': dict({ + 'unit': 'V', + 'value': 231, + }), + 'voltage_ac_phase_to_phase_12': dict({ + 'unit': 'V', + 'value': 395.9, + }), + 'voltage_ac_phase_to_phase_23': dict({ + 'unit': 'V', + 'value': 398, + }), + 'voltage_ac_phase_to_phase_31': dict({ + 'unit': 'V', + 'value': 398, + }), + }), + }), + 'ohmpilot': None, + 'power_flow': dict({ + 'power_flow': dict({ + 'energy_day': dict({ + 'unit': 'Wh', + 'value': 1101.7000732421875, + }), + 'energy_total': dict({ + 'unit': 'Wh', + 'value': 44188000, + }), + 'energy_year': dict({ + 'unit': 'Wh', + 'value': 25508788, + }), + 'meter_location': dict({ + 'value': 'grid', + }), + 'meter_mode': dict({ + 'value': 'meter', + }), + 'power_battery': dict({ + 'unit': 'W', + 'value': None, + }), + 'power_grid': dict({ + 'unit': 'W', + 'value': 1703.74, + }), + 'power_load': dict({ + 'unit': 'W', + 'value': -2814.74, + }), + 'power_photovoltaics': dict({ + 'unit': 'W', + 'value': 1111, + }), + 'relative_autonomy': dict({ + 'unit': '%', + 'value': 39.4707859340472, + }), + 'relative_self_consumption': dict({ + 'unit': '%', + 'value': 100, + }), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'timestamp': dict({ + 'value': '2021-10-07T10:00:43+02:00', + }), + }), + }), + 'storage': None, + }), + 'inverter_info': dict({ + 'inverters': list([ + dict({ + 'custom_name': dict({ + 'value': 'Symo 20', + }), + 'device_id': dict({ + 'value': '1', + }), + 'device_type': dict({ + 'manufacturer': 'Fronius', + 'model': 'Symo 20.0-3-M', + 'value': 121, + }), + 'error_code': dict({ + 'value': 0, + }), + 'pv_power': dict({ + 'unit': 'W', + 'value': 23100, + }), + 'show': dict({ + 'value': 1, + }), + 'status_code': dict({ + 'value': 7, + }), + 'unique_id': '**REDACTED**', + }), + ]), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'timestamp': dict({ + 'value': '2021-10-07T13:41:00+02:00', + }), + }), + }) +# --- diff --git a/tests/components/fronius/test_diagnostics.py b/tests/components/fronius/test_diagnostics.py new file mode 100644 index 00000000000..7d8a49dcb7d --- /dev/null +++ b/tests/components/fronius/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Tests for the diagnostics data provided by the KNX integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import mock_responses, setup_fronius_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_responses(aioclient_mock) + entry = await setup_fronius_integration(hass) + + assert ( + await get_diagnostics_for_config_entry( + hass, + hass_client, + entry, + ) + == snapshot + ) From 2f16c3aa80cb3ae230c58f2fd15fcc9bd35de115 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 25 May 2024 18:59:29 +0200 Subject: [PATCH 142/164] Fix mqtt callback typing (#118104) --- homeassistant/components/mqtt/client.py | 7 +++---- homeassistant/components/mqtt/models.py | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index b3fde3f8320..59762e5cb92 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -77,7 +77,6 @@ from .const import ( ) from .models import ( DATA_MQTT, - AsyncMessageCallbackType, MessageCallbackType, MqttData, PublishMessage, @@ -184,7 +183,7 @@ async def async_publish( async def async_subscribe( hass: HomeAssistant, topic: str, - msg_callback: AsyncMessageCallbackType | MessageCallbackType, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], qos: int = DEFAULT_QOS, encoding: str | None = DEFAULT_ENCODING, ) -> CALLBACK_TYPE: @@ -832,7 +831,7 @@ class MQTT: def _exception_message( self, - msg_callback: AsyncMessageCallbackType | MessageCallbackType, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], msg: ReceiveMessage, ) -> str: """Return a string with the exception message.""" @@ -844,7 +843,7 @@ class MQTT: async def async_subscribe( self, topic: str, - msg_callback: AsyncMessageCallbackType | MessageCallbackType, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], qos: int, encoding: str | None = None, ) -> Callable[[], None]: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index bee33b21bca..83248d85135 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -5,7 +5,7 @@ from __future__ import annotations from ast import literal_eval import asyncio from collections import deque -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum import logging @@ -70,7 +70,6 @@ class ReceiveMessage: timestamp: float -type AsyncMessageCallbackType = Callable[[ReceiveMessage], Coroutine[Any, Any, None]] type MessageCallbackType = Callable[[ReceiveMessage], None] From 89e2c57da686f4836f4cb031e4943df662e39571 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 May 2024 11:16:51 -0700 Subject: [PATCH 143/164] Add conversation agent debug tracing (#118124) * Add debug tracing for conversation agents * Minor cleanup --- .../components/conversation/agent_manager.py | 30 +++-- .../components/conversation/trace.py | 118 ++++++++++++++++++ .../conversation.py | 4 + .../components/ollama/conversation.py | 6 + .../openai_conversation/conversation.py | 4 + homeassistant/helpers/llm.py | 10 +- tests/components/conversation/test_entity.py | 7 ++ tests/components/conversation/test_trace.py | 80 ++++++++++++ .../test_conversation.py | 15 +++ tests/components/ollama/test_conversation.py | 14 +++ .../openai_conversation/test_conversation.py | 15 +++ 11 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/conversation/trace.py create mode 100644 tests/components/conversation/test_trace.py diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 9f31ccd6c62..aa8b7644900 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses import logging from typing import Any @@ -20,6 +21,11 @@ from .models import ( ConversationInput, ConversationResult, ) +from .trace import ( + ConversationTraceEvent, + ConversationTraceEventType, + async_conversation_trace, +) _LOGGER = logging.getLogger(__name__) @@ -84,15 +90,23 @@ async def async_converse( language = hass.config.language _LOGGER.debug("Processing in %s: %s", language, text) - return await method( - ConversationInput( - text=text, - context=context, - conversation_id=conversation_id, - device_id=device_id, - language=language, - ) + conversation_input = ConversationInput( + text=text, + context=context, + conversation_id=conversation_id, + device_id=device_id, + language=language, ) + with async_conversation_trace() as trace: + trace.add_event( + ConversationTraceEvent( + ConversationTraceEventType.ASYNC_PROCESS, + dataclasses.asdict(conversation_input), + ) + ) + result = await method(conversation_input) + trace.set_result(**result.as_dict()) + return result class AgentManager: diff --git a/homeassistant/components/conversation/trace.py b/homeassistant/components/conversation/trace.py new file mode 100644 index 00000000000..0bd2fe8ed5b --- /dev/null +++ b/homeassistant/components/conversation/trace.py @@ -0,0 +1,118 @@ +"""Debug traces for conversation.""" + +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import asdict, dataclass, field +import enum +from typing import Any + +from homeassistant.util import dt as dt_util, ulid as ulid_util +from homeassistant.util.limited_size_dict import LimitedSizeDict + +STORED_TRACES = 3 + + +class ConversationTraceEventType(enum.StrEnum): + """Type of an event emitted during a conversation.""" + + ASYNC_PROCESS = "async_process" + """The conversation is started from user input.""" + + AGENT_DETAIL = "agent_detail" + """Event detail added by a conversation agent.""" + + LLM_TOOL_CALL = "llm_tool_call" + """An LLM Tool call""" + + +@dataclass(frozen=True) +class ConversationTraceEvent: + """Event emitted during a conversation.""" + + event_type: ConversationTraceEventType + data: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat()) + + +class ConversationTrace: + """Stores debug data related to a conversation.""" + + def __init__(self) -> None: + """Initialize ConversationTrace.""" + self._trace_id = ulid_util.ulid_now() + self._events: list[ConversationTraceEvent] = [] + self._error: Exception | None = None + self._result: dict[str, Any] = {} + + @property + def trace_id(self) -> str: + """Identifier for this trace.""" + return self._trace_id + + def add_event(self, event: ConversationTraceEvent) -> None: + """Add an event to the trace.""" + self._events.append(event) + + def set_error(self, ex: Exception) -> None: + """Set error.""" + self._error = ex + + def set_result(self, **kwargs: Any) -> None: + """Set result.""" + self._result = {**kwargs} + + def as_dict(self) -> dict[str, Any]: + """Return dictionary version of this ConversationTrace.""" + result: dict[str, Any] = { + "id": self._trace_id, + "events": [asdict(event) for event in self._events], + } + if self._error is not None: + result["error"] = str(self._error) or self._error.__class__.__name__ + if self._result is not None: + result["result"] = self._result + return result + + +_current_trace: ContextVar[ConversationTrace | None] = ContextVar( + "current_trace", default=None +) +_recent_traces: LimitedSizeDict[str, ConversationTrace] = LimitedSizeDict( + size_limit=STORED_TRACES +) + + +def async_conversation_trace_append( + event_type: ConversationTraceEventType, event_data: dict[str, Any] +) -> None: + """Append a ConversationTraceEvent to the current active trace.""" + trace = _current_trace.get() + if not trace: + return + trace.add_event(ConversationTraceEvent(event_type, event_data)) + + +@contextmanager +def async_conversation_trace() -> Generator[ConversationTrace, None]: + """Create a new active ConversationTrace.""" + trace = ConversationTrace() + token = _current_trace.set(trace) + _recent_traces[trace.trace_id] = trace + try: + yield trace + except Exception as ex: + trace.set_error(ex) + raise + finally: + _current_trace.reset(token) + + +def async_get_traces() -> list[ConversationTrace]: + """Get the most recent traces.""" + return list(_recent_traces.values()) + + +def async_clear_traces() -> None: + """Clear all traces.""" + _recent_traces.clear() diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 8a6a761d549..f84bd81f80c 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -12,6 +12,7 @@ import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation +from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -250,6 +251,9 @@ class GoogleGenerativeAIConversationEntity( messages[1] = {"role": "model", "parts": "Ok"} LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages} + ) chat = model.start_chat(history=messages) chat_request = user_input.text diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index cbec719780a..fa7a3c3797e 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -9,6 +9,7 @@ from typing import Literal import ollama from homeassistant.components import assist_pipeline, conversation +from homeassistant.components.conversation import trace from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL @@ -138,6 +139,11 @@ class OllamaConversationEntity( ollama.Message(role=MessageRole.USER.value, content=user_input.text) ) + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + {"messages": message_history.messages}, + ) + # Get response try: response = await client.chat( diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 2bd21429d9f..be3b8ea9126 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -8,6 +8,7 @@ import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation +from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -169,6 +170,9 @@ class OpenAIConversationEntity( messages.append({"role": "user", "content": user_input.text}) LOGGER.debug("Prompt: %s", messages) + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages} + ) client = self.hass.data[DOMAIN][self.entry.entry_id] diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index cde644a7641..1ffc2880547 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -3,12 +3,16 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Any import voluptuous as vol from homeassistant.components.climate.intent import INTENT_GET_TEMPERATURE +from homeassistant.components.conversation.trace import ( + ConversationTraceEventType, + async_conversation_trace_append, +) from homeassistant.components.weather.intent import INTENT_GET_WEATHER from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -116,6 +120,10 @@ class API(ABC): async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: """Call a LLM tool, validate args and return the response.""" + async_conversation_trace_append( + ConversationTraceEventType.LLM_TOOL_CALL, asdict(tool_input) + ) + for tool in self.async_get_tools(): if tool.name == tool_input.tool_name: break diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py index c84f94c4aa4..109c0ed361f 100644 --- a/tests/components/conversation/test_entity.py +++ b/tests/components/conversation/test_entity.py @@ -2,7 +2,9 @@ from unittest.mock import patch +from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -31,6 +33,11 @@ async def test_state_set_and_restore(hass: HomeAssistant) -> None: ) as mock_process, patch("homeassistant.util.dt.utcnow", return_value=now), ): + intent_response = intent.IntentResponse(language="en") + intent_response.async_set_speech("response text") + mock_process.return_value = conversation.ConversationResult( + response=intent_response, + ) await hass.services.async_call( "conversation", "process", diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py new file mode 100644 index 00000000000..c586eb8865d --- /dev/null +++ b/tests/components/conversation/test_trace.py @@ -0,0 +1,80 @@ +"""Test for the conversation traces.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.conversation import trace +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + + +@pytest.fixture +async def init_components(hass: HomeAssistant): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) + + +async def test_converation_trace( + hass: HomeAssistant, + init_components: None, + sl_setup: None, +) -> None: + """Test tracing a conversation.""" + await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + assert last_trace.get("events") + assert len(last_trace.get("events")) == 1 + trace_event = last_trace["events"][0] + assert ( + trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS + ) + assert trace_event.get("data") + assert trace_event["data"].get("text") == "add apples to my shopping list" + assert last_trace.get("result") + assert ( + last_trace["result"] + .get("response", {}) + .get("speech", {}) + .get("plain", {}) + .get("speech") + == "Added apples" + ) + + +async def test_converation_trace_error( + hass: HomeAssistant, + init_components: None, + sl_setup: None, +) -> None: + """Test tracing a conversation.""" + with ( + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", + side_effect=HomeAssistantError("Failed to talk to agent"), + ), + pytest.raises(HomeAssistantError), + ): + await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + assert last_trace.get("events") + assert len(last_trace.get("events")) == 1 + trace_event = last_trace["events"][0] + assert ( + trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS + ) + assert last_trace.get("error") == "Failed to talk to agent" diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index b31d9442a43..4c208c240b8 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -9,6 +9,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation +from homeassistant.components.conversation import trace from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -285,6 +286,20 @@ async def test_function_call( ), ) + # Test conversating tracing + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + trace_events = last_trace.get("events", []) + assert [event["event_type"] for event in trace_events] == [ + trace.ConversationTraceEventType.ASYNC_PROCESS, + trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.LLM_TOOL_CALL, + ] + # AGENT_DETAIL event contains the raw prompt passed to the model + detail_event = trace_events[1] + assert "Answer in plain text" in detail_event["data"]["messages"][0]["parts"] + @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI.async_get_tools" diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 080d0d34f2d..b6f0be3c414 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -6,6 +6,7 @@ from ollama import Message, ResponseError import pytest from homeassistant.components import conversation, ollama +from homeassistant.components.conversation import trace from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL from homeassistant.core import Context, HomeAssistant @@ -110,6 +111,19 @@ async def test_chat( ), result assert result.response.speech["plain"]["speech"] == "test response" + # Test Conversation tracing + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + trace_events = last_trace.get("events", []) + assert [event["event_type"] for event in trace_events] == [ + trace.ConversationTraceEventType.ASYNC_PROCESS, + trace.ConversationTraceEventType.AGENT_DETAIL, + ] + # AGENT_DETAIL event contains the raw prompt passed to the model + detail_event = trace_events[1] + assert "The current time is" in detail_event["data"]["messages"][0]["content"] + async def test_message_history_trimming( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 319295374a7..3fa5c307b6d 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -15,6 +15,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation +from homeassistant.components.conversation import trace from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -200,6 +201,20 @@ async def test_function_call( ), ) + # Test Conversation tracing + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + trace_events = last_trace.get("events", []) + assert [event["event_type"] for event in trace_events] == [ + trace.ConversationTraceEventType.ASYNC_PROCESS, + trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.LLM_TOOL_CALL, + ] + # AGENT_DETAIL event contains the raw prompt passed to the model + detail_event = trace_events[1] + assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] + @patch( "homeassistant.components.openai_conversation.conversation.llm.AssistAPI.async_get_tools" From cee3be5f7af8f7300921c10cd57b1852ed19d7be Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 25 May 2024 21:24:51 +0300 Subject: [PATCH 144/164] Break long strings in LLM tools (#118114) * Break long code strings * Address comments --------- Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/llm.py | 5 ++++- tests/helpers/test_llm.py | 25 +++++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 1ffc2880547..08125acc0da 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -199,7 +199,10 @@ class AssistAPI(API): async def async_get_api_prompt(self, tool_input: ToolInput) -> str: """Return the prompt for the API.""" - prompt = "Call the intent tools to control Home Assistant. Just pass the name to the intent." + prompt = ( + "Call the intent tools to control Home Assistant. " + "Just pass the name to the intent." + ) if tool_input.device_id: device_reg = device_registry.async_get(self.hass) device = device_reg.async_get(tool_input.device_id) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 70c28545483..e3308b89061 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -174,9 +174,9 @@ async def test_assist_api_prompt( ) api = llm.async_get_api(hass, "assist") prompt = await api.async_get_api_prompt(tool_input) - assert ( - prompt - == "Call the intent tools to control Home Assistant. Just pass the name to the intent." + assert prompt == ( + "Call the intent tools to control Home Assistant." + " Just pass the name to the intent." ) entry = MockConfigEntry(title=None) @@ -190,18 +190,18 @@ async def test_assist_api_prompt( suggested_area="Test Area", ).id prompt = await api.async_get_api_prompt(tool_input) - assert ( - prompt - == "Call the intent tools to control Home Assistant. Just pass the name to the intent. You are in Test Area." + assert prompt == ( + "Call the intent tools to control Home Assistant." + " Just pass the name to the intent. You are in Test Area." ) floor = floor_registry.async_create("second floor") area = area_registry.async_get_area_by_name("Test Area") area_registry.async_update(area.id, floor_id=floor.floor_id) prompt = await api.async_get_api_prompt(tool_input) - assert ( - prompt - == "Call the intent tools to control Home Assistant. Just pass the name to the intent. You are in Test Area (second floor)." + assert prompt == ( + "Call the intent tools to control Home Assistant." + " Just pass the name to the intent. You are in Test Area (second floor)." ) context.user_id = "12345" @@ -210,7 +210,8 @@ async def test_assist_api_prompt( mock_user.name = "Test User" with patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user): prompt = await api.async_get_api_prompt(tool_input) - assert ( - prompt - == "Call the intent tools to control Home Assistant. Just pass the name to the intent. You are in Test Area (second floor). The user name is Test User." + assert prompt == ( + "Call the intent tools to control Home Assistant." + " Just pass the name to the intent. You are in Test Area (second floor)." + " The user name is Test User." ) From 569763b7a83411eecfdd1a9f60c4ab7f13bd68b6 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 25 May 2024 22:13:32 +0200 Subject: [PATCH 145/164] Reach platinum level in Minecraft Server (#105432) Reach platinum level --- homeassistant/components/minecraft_server/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index a00936852f0..8e098f98a15 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["mcstatus==11.1.1"] } From 521ed0a220b24ceb47e5e0e924d336c39516d6dd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 22:46:33 +0200 Subject: [PATCH 146/164] Fix mqtt callback exception logging (#118138) * Fix mqtt callback exception logging * Improve code * Add test --- homeassistant/components/mqtt/client.py | 7 ++++- tests/components/mqtt/test_init.py | 39 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 59762e5cb92..0e9f7f06e21 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -835,8 +835,13 @@ class MQTT: msg: ReceiveMessage, ) -> str: """Return a string with the exception message.""" + # if msg_callback is a partial we return the name of the first argument + if isinstance(msg_callback, partial): + call_back_name = getattr(msg_callback.args[0], "__name__") # type: ignore[unreachable] + else: + call_back_name = getattr(msg_callback, "__name__") return ( - f"Exception in {msg_callback.__name__} when handling msg on " + f"Exception in {call_back_name} when handling msg on " f"'{msg.topic}': '{msg.payload}'" # type: ignore[str-bytes-safe] ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 08f1d8ca099..57056819784 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Generator from copy import deepcopy from datetime import datetime, timedelta +from functools import partial import json import logging import socket @@ -2912,8 +2913,8 @@ async def test_message_callback_exception_gets_logged( await mqtt_mock_entry() @callback - def bad_handler(*args) -> None: - """Record calls.""" + def bad_handler(msg: ReceiveMessage) -> None: + """Handle callback.""" raise ValueError("This is a bad message callback") await mqtt.async_subscribe(hass, "test-topic", bad_handler) @@ -2926,6 +2927,40 @@ async def test_message_callback_exception_gets_logged( ) +@pytest.mark.no_fail_on_log_exception +async def test_message_partial_callback_exception_gets_logged( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test exception raised by message handler.""" + await mqtt_mock_entry() + + @callback + def bad_handler(msg: ReceiveMessage) -> None: + """Handle callback.""" + raise ValueError("This is a bad message callback") + + def parial_handler( + msg_callback: MessageCallbackType, + attributes: set[str], + msg: ReceiveMessage, + ) -> None: + """Partial callback handler.""" + msg_callback(msg) + + await mqtt.async_subscribe( + hass, "test-topic", partial(parial_handler, bad_handler, {"some_attr"}) + ) + async_fire_mqtt_message(hass, "test-topic", "test") + await hass.async_block_till_done() + + assert ( + "Exception in bad_handler when handling msg on 'test-topic':" + " 'test'" in caplog.text + ) + + async def test_mqtt_ws_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 5d7a735da698e602bfee6b42fbf51769515d15fe Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:07:50 +0200 Subject: [PATCH 147/164] Rework mqtt callbacks for device_tracker (#118110) --- .../components/mqtt/device_tracker.py | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 519af19ac16..9af85d5ab9f 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging from typing import TYPE_CHECKING @@ -32,13 +33,7 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_PAYLOAD_RESET, CONF_QOS, CONF_STATE_TOPIC -from .debug_info import log_messages -from .mixins import ( - CONF_JSON_ATTRS_TOPIC, - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic @@ -119,33 +114,31 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _tracker_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = self._value_template(msg.payload) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + if payload == self._config[CONF_PAYLOAD_HOME]: + self._location_name = STATE_HOME + elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: + self._location_name = STATE_NOT_HOME + elif payload == self._config[CONF_PAYLOAD_RESET]: + self._location_name = None + else: + if TYPE_CHECKING: + assert isinstance(msg.payload, str) + self._location_name = msg.payload + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_location_name"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload = self._value_template(msg.payload) - if not payload.strip(): # No output from template, ignore - _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, - msg.topic, - ) - return - if payload == self._config[CONF_PAYLOAD_HOME]: - self._location_name = STATE_HOME - elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: - self._location_name = STATE_NOT_HOME - elif payload == self._config[CONF_PAYLOAD_RESET]: - self._location_name = None - else: - if TYPE_CHECKING: - assert isinstance(msg.payload, str) - self._location_name = msg.payload - state_topic: str | None = self._config.get(CONF_STATE_TOPIC) if state_topic is None: return @@ -155,7 +148,12 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): { "state_topic": { "topic": state_topic, - "msg_callback": message_received, + "msg_callback": partial( + self._message_callback, + self._tracker_message_received, + {"_location_name"}, + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], } }, From b4acadc992b13b0029cff458f23d21d5c6cc2118 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:09:24 +0200 Subject: [PATCH 148/164] Rework mqtt callbacks for fan (#118115) --- homeassistant/components/mqtt/fan.py | 247 ++++++++++++++------------- 1 file changed, 124 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 10571043fb8..1ee7bc63796 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging import math from typing import Any @@ -49,12 +50,7 @@ from .const import ( CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -338,137 +334,142 @@ class MqttFan(MqttEntity, FanEntity): for key, tpl in value_templates.items() } + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + payload = self._value_templates[CONF_STATE](msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) + return + if payload == self._payload["STATE_ON"]: + self._attr_is_on = True + elif payload == self._payload["STATE_OFF"]: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None + + @callback + def _percentage_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the percentage.""" + rendered_percentage_payload = self._value_templates[ATTR_PERCENTAGE]( + msg.payload + ) + if not rendered_percentage_payload: + _LOGGER.debug("Ignoring empty speed from '%s'", msg.topic) + return + if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: + self._attr_percentage = None + return + try: + percentage = ranged_value_to_percentage( + self._speed_range, int(rendered_percentage_payload) + ) + except ValueError: + _LOGGER.warning( + ( + "'%s' received on topic %s. '%s' is not a valid speed within" + " the speed range" + ), + msg.payload, + msg.topic, + rendered_percentage_payload, + ) + return + if percentage < 0 or percentage > 100: + _LOGGER.warning( + ( + "'%s' received on topic %s. '%s' is not a valid speed within" + " the speed range" + ), + msg.payload, + msg.topic, + rendered_percentage_payload, + ) + return + self._attr_percentage = percentage + + @callback + def _preset_mode_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for preset mode.""" + preset_mode = str(self._value_templates[ATTR_PRESET_MODE](msg.payload)) + if preset_mode == self._payload["PRESET_MODE_RESET"]: + self._attr_preset_mode = None + return + if not preset_mode: + _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) + return + if not self.preset_modes or preset_mode not in self.preset_modes: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid preset mode", + msg.payload, + msg.topic, + preset_mode, + ) + return + + self._attr_preset_mode = preset_mode + + @callback + def _oscillation_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the oscillation.""" + payload = self._value_templates[ATTR_OSCILLATING](msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty oscillation from '%s'", msg.topic) + return + if payload == self._payload["OSCILLATE_ON_PAYLOAD"]: + self._attr_oscillating = True + elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]: + self._attr_oscillating = False + + @callback + def _direction_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the direction.""" + direction = self._value_templates[ATTR_DIRECTION](msg.payload) + if not direction: + _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) + return + self._attr_current_direction = str(direction) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} - def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: + def add_subscribe_topic( + topic: str, msg_callback: MessageCallbackType, tracked_attributes: set[str] + ) -> bool: """Add a topic to subscribe to.""" if has_topic := self._topic[topic] is not None: topics[topic] = { "topic": self._topic[topic], - "msg_callback": msg_callback, + "msg_callback": partial( + self._message_callback, msg_callback, tracked_attributes + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } return has_topic - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message.""" - payload = self._value_templates[CONF_STATE](msg.payload) - if not payload: - _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) - return - if payload == self._payload["STATE_ON"]: - self._attr_is_on = True - elif payload == self._payload["STATE_OFF"]: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - - add_subscribe_topic(CONF_STATE_TOPIC, state_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_percentage"}) - def percentage_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the percentage.""" - rendered_percentage_payload = self._value_templates[ATTR_PERCENTAGE]( - msg.payload - ) - if not rendered_percentage_payload: - _LOGGER.debug("Ignoring empty speed from '%s'", msg.topic) - return - if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: - self._attr_percentage = None - return - try: - percentage = ranged_value_to_percentage( - self._speed_range, int(rendered_percentage_payload) - ) - except ValueError: - _LOGGER.warning( - ( - "'%s' received on topic %s. '%s' is not a valid speed within" - " the speed range" - ), - msg.payload, - msg.topic, - rendered_percentage_payload, - ) - return - if percentage < 0 or percentage > 100: - _LOGGER.warning( - ( - "'%s' received on topic %s. '%s' is not a valid speed within" - " the speed range" - ), - msg.payload, - msg.topic, - rendered_percentage_payload, - ) - return - self._attr_percentage = percentage - - add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_preset_mode"}) - def preset_mode_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for preset mode.""" - preset_mode = str(self._value_templates[ATTR_PRESET_MODE](msg.payload)) - if preset_mode == self._payload["PRESET_MODE_RESET"]: - self._attr_preset_mode = None - return - if not preset_mode: - _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) - return - if not self.preset_modes or preset_mode not in self.preset_modes: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid preset mode", - msg.payload, - msg.topic, - preset_mode, - ) - return - - self._attr_preset_mode = preset_mode - - add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_oscillating"}) - def oscillation_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the oscillation.""" - payload = self._value_templates[ATTR_OSCILLATING](msg.payload) - if not payload: - _LOGGER.debug("Ignoring empty oscillation from '%s'", msg.topic) - return - if payload == self._payload["OSCILLATE_ON_PAYLOAD"]: - self._attr_oscillating = True - elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]: - self._attr_oscillating = False - - if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): + add_subscribe_topic(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) + add_subscribe_topic( + CONF_PERCENTAGE_STATE_TOPIC, self._percentage_received, {"_attr_percentage"} + ) + add_subscribe_topic( + CONF_PRESET_MODE_STATE_TOPIC, + self._preset_mode_received, + {"_attr_preset_mode"}, + ) + if add_subscribe_topic( + CONF_OSCILLATION_STATE_TOPIC, + self._oscillation_received, + {"_attr_oscillating"}, + ): self._attr_oscillating = False - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_direction"}) - def direction_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the direction.""" - direction = self._value_templates[ATTR_DIRECTION](msg.payload) - if not direction: - _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) - return - self._attr_current_direction = str(direction) - - add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) + add_subscribe_topic( + CONF_DIRECTION_STATE_TOPIC, + self._direction_received, + {"_attr_current_direction"}, + ) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics From 6580a07308739f30b063e9489f4c90c9c5892204 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:11:07 +0200 Subject: [PATCH 149/164] Refactor mqtt callbacks for humidifier (#118116) --- homeassistant/components/mqtt/humidifier.py | 290 ++++++++++---------- 1 file changed, 144 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index b9f57dfe0ef..7956a05d20a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging from typing import Any @@ -51,12 +52,7 @@ from .const import ( CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -284,164 +280,166 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): topics: dict[str, dict[str, Any]], topic: str, msg_callback: Callable[[ReceiveMessage], None], + tracked_attributes: set[str], ) -> None: """Add a subscription.""" qos: int = self._config[CONF_QOS] if topic in self._topic and self._topic[topic] is not None: topics[topic] = { "topic": self._topic[topic], - "msg_callback": msg_callback, + "msg_callback": partial( + self._message_callback, msg_callback, tracked_attributes + ), + "entity_id": self.entity_id, "qos": qos, "encoding": self._config[CONF_ENCODING] or None, } + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + payload = self._value_templates[CONF_STATE](msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) + return + if payload == self._payload["STATE_ON"]: + self._attr_is_on = True + elif payload == self._payload["STATE_OFF"]: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None + + @callback + def _action_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + action_payload = self._value_templates[ATTR_ACTION](msg.payload) + if not action_payload or action_payload == PAYLOAD_NONE: + _LOGGER.debug("Ignoring empty action from '%s'", msg.topic) + return + try: + self._attr_action = HumidifierAction(str(action_payload)) + except ValueError: + _LOGGER.error( + "'%s' received on topic %s. '%s' is not a valid action", + msg.payload, + msg.topic, + action_payload, + ) + return + + @callback + def _current_humidity_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the current humidity.""" + rendered_current_humidity_payload = self._value_templates[ + ATTR_CURRENT_HUMIDITY + ](msg.payload) + if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: + self._attr_current_humidity = None + return + if not rendered_current_humidity_payload: + _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) + return + try: + current_humidity = round(float(rendered_current_humidity_payload)) + except ValueError: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + if current_humidity < 0 or current_humidity > 100: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + self._attr_current_humidity = current_humidity + + @callback + def _target_humidity_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the target humidity.""" + rendered_target_humidity_payload = self._value_templates[ATTR_HUMIDITY]( + msg.payload + ) + if not rendered_target_humidity_payload: + _LOGGER.debug("Ignoring empty target humidity from '%s'", msg.topic) + return + if rendered_target_humidity_payload == self._payload["HUMIDITY_RESET"]: + self._attr_target_humidity = None + return + try: + target_humidity = round(float(rendered_target_humidity_payload)) + except ValueError: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid target humidity", + msg.payload, + msg.topic, + rendered_target_humidity_payload, + ) + return + if ( + target_humidity < self._attr_min_humidity + or target_humidity > self._attr_max_humidity + ): + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid target humidity", + msg.payload, + msg.topic, + rendered_target_humidity_payload, + ) + return + self._attr_target_humidity = target_humidity + + @callback + def _mode_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for mode.""" + mode = str(self._value_templates[ATTR_MODE](msg.payload)) + if mode == self._payload["MODE_RESET"]: + self._attr_mode = None + return + if not mode: + _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) + return + if not self.available_modes or mode not in self.available_modes: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid mode", + msg.payload, + msg.topic, + mode, + ) + return + + self._attr_mode = mode + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message.""" - payload = self._value_templates[CONF_STATE](msg.payload) - if not payload: - _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) - return - if payload == self._payload["STATE_ON"]: - self._attr_is_on = True - elif payload == self._payload["STATE_OFF"]: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - - self.add_subscription(topics, CONF_STATE_TOPIC, state_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_action"}) - def action_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message.""" - action_payload = self._value_templates[ATTR_ACTION](msg.payload) - if not action_payload or action_payload == PAYLOAD_NONE: - _LOGGER.debug("Ignoring empty action from '%s'", msg.topic) - return - try: - self._attr_action = HumidifierAction(str(action_payload)) - except ValueError: - _LOGGER.error( - "'%s' received on topic %s. '%s' is not a valid action", - msg.payload, - msg.topic, - action_payload, - ) - return - - self.add_subscription(topics, CONF_ACTION_TOPIC, action_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_humidity"}) - def current_humidity_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the current humidity.""" - rendered_current_humidity_payload = self._value_templates[ - ATTR_CURRENT_HUMIDITY - ](msg.payload) - if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: - self._attr_current_humidity = None - return - if not rendered_current_humidity_payload: - _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) - return - try: - current_humidity = round(float(rendered_current_humidity_payload)) - except ValueError: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid humidity", - msg.payload, - msg.topic, - rendered_current_humidity_payload, - ) - return - if current_humidity < 0 or current_humidity > 100: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid humidity", - msg.payload, - msg.topic, - rendered_current_humidity_payload, - ) - return - self._attr_current_humidity = current_humidity - self.add_subscription( - topics, CONF_CURRENT_HUMIDITY_TOPIC, current_humidity_received + topics, CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"} ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_target_humidity"}) - def target_humidity_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the target humidity.""" - rendered_target_humidity_payload = self._value_templates[ATTR_HUMIDITY]( - msg.payload - ) - if not rendered_target_humidity_payload: - _LOGGER.debug("Ignoring empty target humidity from '%s'", msg.topic) - return - if rendered_target_humidity_payload == self._payload["HUMIDITY_RESET"]: - self._attr_target_humidity = None - return - try: - target_humidity = round(float(rendered_target_humidity_payload)) - except ValueError: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid target humidity", - msg.payload, - msg.topic, - rendered_target_humidity_payload, - ) - return - if ( - target_humidity < self._attr_min_humidity - or target_humidity > self._attr_max_humidity - ): - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid target humidity", - msg.payload, - msg.topic, - rendered_target_humidity_payload, - ) - return - self._attr_target_humidity = target_humidity - self.add_subscription( - topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, target_humidity_received + topics, CONF_ACTION_TOPIC, self._action_received, {"_attr_action"} + ) + self.add_subscription( + topics, + CONF_CURRENT_HUMIDITY_TOPIC, + self._current_humidity_received, + {"_attr_current_humidity"}, + ) + self.add_subscription( + topics, + CONF_TARGET_HUMIDITY_STATE_TOPIC, + self._target_humidity_received, + {"_attr_target_humidity"}, + ) + self.add_subscription( + topics, CONF_MODE_STATE_TOPIC, self._mode_received, {"_attr_mode"} ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_mode"}) - def mode_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for mode.""" - mode = str(self._value_templates[ATTR_MODE](msg.payload)) - if mode == self._payload["MODE_RESET"]: - self._attr_mode = None - return - if not mode: - _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) - return - if not self.available_modes or mode not in self.available_modes: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid mode", - msg.payload, - msg.topic, - mode, - ) - return - - self._attr_mode = mode - - self.add_subscription(topics, CONF_MODE_STATE_TOPIC, mode_received) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics From 05d8ec85aa6102dd55c7965e991d77f3f8f45ace Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:13:14 +0200 Subject: [PATCH 150/164] Refactor mqtt callbacks for lock (#118118) --- homeassistant/components/mqtt/lock.py | 87 +++++++++++++-------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 3dfd2b2e6d2..33d25b168a8 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging import re from typing import Any @@ -36,12 +37,7 @@ from .const import ( CONF_STATE_OPENING, CONF_STATE_TOPIC, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -186,57 +182,58 @@ class MqttLock(MqttEntity, LockEntity): self._valid_states = [config[state] for state in STATE_CONFIG_KEYS] + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new lock state messages.""" + payload = self._value_template(msg.payload) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + if payload == self._config[CONF_PAYLOAD_RESET]: + # Reset the state to `unknown` + self._attr_is_locked = None + elif payload in self._valid_states: + self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] + self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] + self._attr_is_open = payload == self._config[CONF_STATE_OPEN] + self._attr_is_opening = payload == self._config[CONF_STATE_OPENING] + self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] + self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - topics: dict[str, dict[str, Any]] = {} + topics: dict[str, dict[str, Any]] qos: int = self._config[CONF_QOS] encoding: str | None = self._config[CONF_ENCODING] or None - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, - { - "_attr_is_jammed", - "_attr_is_locked", - "_attr_is_locking", - "_attr_is_open", - "_attr_is_opening", - "_attr_is_unlocking", - }, - ) - def message_received(msg: ReceiveMessage) -> None: - """Handle new lock state messages.""" - payload = self._value_template(msg.payload) - if not payload.strip(): # No output from template, ignore - _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, - msg.topic, - ) - return - if payload == self._config[CONF_PAYLOAD_RESET]: - # Reset the state to `unknown` - self._attr_is_locked = None - elif payload in self._valid_states: - self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] - self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] - self._attr_is_open = payload == self._config[CONF_STATE_OPEN] - self._attr_is_opening = payload == self._config[CONF_STATE_OPENING] - self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] - self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True - else: - topics[CONF_STATE_TOPIC] = { + return + topics = { + CONF_STATE_TOPIC: { "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": message_received, + "msg_callback": partial( + self._message_callback, + self._message_received, + { + "_attr_is_jammed", + "_attr_is_locked", + "_attr_is_locking", + "_attr_is_open", + "_attr_is_opening", + "_attr_is_unlocking", + }, + ), + "entity_id": self.entity_id, CONF_QOS: qos, CONF_ENCODING: encoding, } + } self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, From e30297d8377af716d8a6cb6db5a99eacdac2262d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:13:43 +0200 Subject: [PATCH 151/164] Refactor mqtt callbacks for lawn_mower (#118117) --- homeassistant/components/mqtt/lawn_mower.py | 94 ++++++++++----------- 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 7380f478e2c..3ce04ca29d5 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import contextlib +from functools import partial import logging import voluptuous as vol @@ -31,12 +32,7 @@ from .const import ( DEFAULT_OPTIMISTIC, DEFAULT_RETAIN, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -150,53 +146,55 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): config.get(CONF_START_MOWING_COMMAND_TEMPLATE), entity=self ).async_render + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = str(self._value_template(msg.payload)) + if not payload: + _LOGGER.debug( + "Invalid empty activity payload from topic %s, for entity %s", + msg.topic, + self.entity_id, + ) + return + if payload.lower() == "none": + self._attr_activity = None + return + + try: + self._attr_activity = LawnMowerActivity(payload) + except ValueError: + _LOGGER.error( + "Invalid activity for %s: '%s' (valid activities: %s)", + self.entity_id, + payload, + [option.value for option in LawnMowerActivity], + ) + return + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_activity"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload = str(self._value_template(msg.payload)) - if not payload: - _LOGGER.debug( - "Invalid empty activity payload from topic %s, for entity %s", - msg.topic, - self.entity_id, - ) - return - if payload.lower() == "none": - self._attr_activity = None - return - - try: - self._attr_activity = LawnMowerActivity(payload) - except ValueError: - _LOGGER.error( - "Invalid activity for %s: '%s' (valid activities: %s)", - self.entity_id, - payload, - [option.value for option in LawnMowerActivity], - ) - return - if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: # Force into optimistic mode. self._attr_assumed_state = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_ACTIVITY_STATE_TOPIC: { - "topic": self._config.get(CONF_ACTIVITY_STATE_TOPIC), - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + { + CONF_ACTIVITY_STATE_TOPIC: { + "topic": self._config.get(CONF_ACTIVITY_STATE_TOPIC), + "msg_callback": partial( + self._message_callback, + self._message_received, + {"_attr_activity"}, + ), + "entity_id": self.entity_id, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + }, + ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" From 0f44ebd51ec30b7932cecb45b178317db55fd479 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:14:48 +0200 Subject: [PATCH 152/164] Refactor mqtt callbacks for update platform (#118131) --- homeassistant/components/mqtt/update.py | 180 ++++++++++++------------ 1 file changed, 91 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 25cc60155a0..9b6ee901eaf 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import partial import logging from typing import Any, TypedDict, cast @@ -32,12 +33,7 @@ from .const import ( CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic @@ -141,25 +137,104 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): ).async_render_with_possible_json_value, } + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + + if not payload or payload == PAYLOAD_EMPTY_JSON: + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + + json_payload: _MqttUpdatePayloadType = {} + try: + rendered_json_payload = json_loads(payload) + if isinstance(rendered_json_payload, dict): + _LOGGER.debug( + ( + "JSON payload detected after processing payload '%s' on" + " topic %s" + ), + rendered_json_payload, + msg.topic, + ) + json_payload = cast(_MqttUpdatePayloadType, rendered_json_payload) + else: + _LOGGER.debug( + ( + "Non-dictionary JSON payload detected after processing" + " payload '%s' on topic %s" + ), + payload, + msg.topic, + ) + json_payload = {"installed_version": str(payload)} + except JSON_DECODE_EXCEPTIONS: + _LOGGER.debug( + ( + "No valid (JSON) payload detected after processing payload '%s'" + " on topic %s" + ), + payload, + msg.topic, + ) + json_payload["installed_version"] = str(payload) + + if "installed_version" in json_payload: + self._attr_installed_version = json_payload["installed_version"] + + if "latest_version" in json_payload: + self._attr_latest_version = json_payload["latest_version"] + + if "title" in json_payload: + self._attr_title = json_payload["title"] + + if "release_summary" in json_payload: + self._attr_release_summary = json_payload["release_summary"] + + if "release_url" in json_payload: + self._attr_release_url = json_payload["release_url"] + + if "entity_picture" in json_payload: + self._entity_picture = json_payload["entity_picture"] + + @callback + def _handle_latest_version_received(self, msg: ReceiveMessage) -> None: + """Handle receiving latest version via MQTT.""" + latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) + + if isinstance(latest_version, str) and latest_version != "": + self._attr_latest_version = latest_version + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} def add_subscription( - topics: dict[str, Any], topic: str, msg_callback: MessageCallbackType + topics: dict[str, Any], + topic: str, + msg_callback: MessageCallbackType, + tracked_attributes: set[str], ) -> None: if self._config.get(topic) is not None: topics[topic] = { "topic": self._config[topic], - "msg_callback": msg_callback, + "msg_callback": partial( + self._message_callback, msg_callback, tracked_attributes + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + add_subscription( + topics, + CONF_STATE_TOPIC, + self._handle_state_message_received, { "_attr_installed_version", "_attr_latest_version", @@ -169,84 +244,11 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): "_entity_picture", }, ) - def handle_state_message_received(msg: ReceiveMessage) -> None: - """Handle receiving state message via MQTT.""" - payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) - - if not payload or payload == PAYLOAD_EMPTY_JSON: - _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, - msg.topic, - ) - return - - json_payload: _MqttUpdatePayloadType = {} - try: - rendered_json_payload = json_loads(payload) - if isinstance(rendered_json_payload, dict): - _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), - rendered_json_payload, - msg.topic, - ) - json_payload = cast(_MqttUpdatePayloadType, rendered_json_payload) - else: - _LOGGER.debug( - ( - "Non-dictionary JSON payload detected after processing" - " payload '%s' on topic %s" - ), - payload, - msg.topic, - ) - json_payload = {"installed_version": str(payload)} - except JSON_DECODE_EXCEPTIONS: - _LOGGER.debug( - ( - "No valid (JSON) payload detected after processing payload '%s'" - " on topic %s" - ), - payload, - msg.topic, - ) - json_payload["installed_version"] = str(payload) - - if "installed_version" in json_payload: - self._attr_installed_version = json_payload["installed_version"] - - if "latest_version" in json_payload: - self._attr_latest_version = json_payload["latest_version"] - - if "title" in json_payload: - self._attr_title = json_payload["title"] - - if "release_summary" in json_payload: - self._attr_release_summary = json_payload["release_summary"] - - if "release_url" in json_payload: - self._attr_release_url = json_payload["release_url"] - - if "entity_picture" in json_payload: - self._entity_picture = json_payload["entity_picture"] - - add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_latest_version"}) - def handle_latest_version_received(msg: ReceiveMessage) -> None: - """Handle receiving latest version via MQTT.""" - latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) - - if isinstance(latest_version, str) and latest_version != "": - self._attr_latest_version = latest_version - add_subscription( - topics, CONF_LATEST_VERSION_TOPIC, handle_latest_version_received + topics, + CONF_LATEST_VERSION_TOPIC, + self._handle_latest_version_received, + {"_attr_latest_version"}, ) self._sub_state = subscription.async_prepare_subscribe_topics( From c510031fcffbea697192719a999f4a53d0de8c35 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:15:22 +0200 Subject: [PATCH 153/164] Refactor mqtt callbacks for siren (#118125) --- homeassistant/components/mqtt/siren.py | 156 ++++++++++++------------- 1 file changed, 77 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 9188e3d03ae..5920efbc3c1 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging from typing import Any, cast @@ -48,12 +49,7 @@ from .const import ( PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -205,88 +201,90 @@ class MqttSiren(MqttEntity, SirenEntity): entity=self, ).async_render_with_possible_json_value - def _prepare_subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on", "_extra_attributes"}) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT state messages.""" - payload = self._value_template(msg.payload) - if not payload or payload == PAYLOAD_EMPTY_JSON: + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload = self._value_template(msg.payload) + if not payload or payload == PAYLOAD_EMPTY_JSON: + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + json_payload: dict[str, Any] = {} + if payload in [self._state_on, self._state_off, PAYLOAD_NONE]: + json_payload = {STATE: payload} + else: + try: + json_payload = json_loads_object(payload) _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, + ( + "JSON payload detected after processing payload '%s' on" + " topic %s" + ), + json_payload, + msg.topic, + ) + except JSON_DECODE_EXCEPTIONS: + _LOGGER.warning( + ( + "No valid (JSON) payload detected after processing payload" + " '%s' on topic %s" + ), + json_payload, msg.topic, ) return - json_payload: dict[str, Any] = {} - if payload in [self._state_on, self._state_off, PAYLOAD_NONE]: - json_payload = {STATE: payload} - else: - try: - json_payload = json_loads_object(payload) - _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), - json_payload, - msg.topic, - ) - except JSON_DECODE_EXCEPTIONS: - _LOGGER.warning( - ( - "No valid (JSON) payload detected after processing payload" - " '%s' on topic %s" - ), - json_payload, - msg.topic, - ) - return - if STATE in json_payload: - if json_payload[STATE] == self._state_on: - self._attr_is_on = True - if json_payload[STATE] == self._state_off: - self._attr_is_on = False - if json_payload[STATE] == PAYLOAD_NONE: - self._attr_is_on = None - del json_payload[STATE] + if STATE in json_payload: + if json_payload[STATE] == self._state_on: + self._attr_is_on = True + if json_payload[STATE] == self._state_off: + self._attr_is_on = False + if json_payload[STATE] == PAYLOAD_NONE: + self._attr_is_on = None + del json_payload[STATE] - if json_payload: - # process attributes - try: - params: SirenTurnOnServiceParameters - params = vol.All(TURN_ON_SCHEMA)(json_payload) - except vol.MultipleInvalid as invalid_siren_parameters: - _LOGGER.warning( - "Unable to update siren state attributes from payload '%s': %s", - json_payload, - invalid_siren_parameters, - ) - return - # To be able to track changes to self._extra_attributes we assign - # a fresh copy to make the original tracked reference immutable. - self._extra_attributes = dict(self._extra_attributes) - self._update(process_turn_on_params(self, params)) + if json_payload: + # process attributes + try: + params: SirenTurnOnServiceParameters + params = vol.All(TURN_ON_SCHEMA)(json_payload) + except vol.MultipleInvalid as invalid_siren_parameters: + _LOGGER.warning( + "Unable to update siren state attributes from payload '%s': %s", + json_payload, + invalid_siren_parameters, + ) + return + # To be able to track changes to self._extra_attributes we assign + # a fresh copy to make the original tracked reference immutable. + self._extra_attributes = dict(self._extra_attributes) + self._update(process_turn_on_params(self, params)) + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_STATE_TOPIC: { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": state_message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + { + CONF_STATE_TOPIC: { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": partial( + self._message_callback, + self._state_message_received, + {"_attr_is_on", "_extra_attributes"}, + ), + "entity_id": self.entity_id, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + }, + ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" From 3dbe9a41af6969503c20bf8ba33e484507e65eb6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:15:53 +0200 Subject: [PATCH 154/164] Refactor mqtt callbacks for number (#118119) --- homeassistant/components/mqtt/number.py | 108 ++++++++++++------------ 1 file changed, 53 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 74d768ae598..f381087bd37 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging import voluptuous as vol @@ -41,12 +42,7 @@ from .const import ( CONF_RETAIN, CONF_STATE_TOPIC, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -165,60 +161,62 @@ class MqttNumber(MqttEntity, RestoreNumber): self._attr_native_step = config[CONF_STEP] self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + num_value: int | float | None + payload = str(self._value_template(msg.payload)) + if not payload.strip(): + _LOGGER.debug("Ignoring empty state update from '%s'", msg.topic) + return + try: + if payload == self._config[CONF_PAYLOAD_RESET]: + num_value = None + elif payload.isnumeric(): + num_value = int(payload) + else: + num_value = float(payload) + except ValueError: + _LOGGER.warning("Payload '%s' is not a Number", msg.payload) + return + + if num_value is not None and ( + num_value < self.min_value or num_value > self.max_value + ): + _LOGGER.error( + "Invalid value for %s: %s (range %s - %s)", + self.entity_id, + num_value, + self.min_value, + self.max_value, + ) + return + + self._attr_native_value = num_value + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_native_value"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - num_value: int | float | None - payload = str(self._value_template(msg.payload)) - if not payload.strip(): - _LOGGER.debug("Ignoring empty state update from '%s'", msg.topic) - return - try: - if payload == self._config[CONF_PAYLOAD_RESET]: - num_value = None - elif payload.isnumeric(): - num_value = int(payload) - else: - num_value = float(payload) - except ValueError: - _LOGGER.warning("Payload '%s' is not a Number", msg.payload) - return - - if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value - ): - _LOGGER.error( - "Invalid value for %s: %s (range %s - %s)", - self.entity_id, - num_value, - self.min_value, - self.max_value, - ) - return - - self._attr_native_value = num_value - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._attr_assumed_state = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + { + "state_topic": { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": partial( + self._message_callback, + self._message_received, + {"_attr_native_value"}, + ), + "entity_id": self.entity_id, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + }, + ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" From e740e2cdc185131eb8d6167c80be066f44bf8c5b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:16:16 +0200 Subject: [PATCH 155/164] Refactor mqtt callbacks for select platform (#118121) --- homeassistant/components/mqtt/select.py | 92 ++++++++++++------------- 1 file changed, 45 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 05df697764d..f37a2b1e231 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging import voluptuous as vol @@ -27,12 +28,7 @@ from .const import ( CONF_RETAIN, CONF_STATE_TOPIC, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -113,52 +109,54 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = str(self._value_template(msg.payload)) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + if payload.lower() == "none": + self._attr_current_option = None + return + + if payload not in self.options: + _LOGGER.error( + "Invalid option for %s: '%s' (valid options: %s)", + self.entity_id, + payload, + self.options, + ) + return + self._attr_current_option = payload + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_option"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload = str(self._value_template(msg.payload)) - if not payload.strip(): # No output from template, ignore - _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, - msg.topic, - ) - return - if payload.lower() == "none": - self._attr_current_option = None - return - - if payload not in self.options: - _LOGGER.error( - "Invalid option for %s: '%s' (valid options: %s)", - self.entity_id, - payload, - self.options, - ) - return - self._attr_current_option = payload - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._attr_assumed_state = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + { + "state_topic": { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": partial( + self._message_callback, + self._message_received, + {"_attr_current_option"}, + ), + "entity_id": self.entity_id, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + }, + ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" From 6b1b15ef9b625b99a70e089abfeddb12ff19367f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:16:54 +0200 Subject: [PATCH 156/164] Refactor mqtt callbacks for text (#118130) --- homeassistant/components/mqtt/text.py | 43 +++++++++++++++------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index c9b0a6c9d70..c563195e6e0 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging import re from typing import Any @@ -34,12 +35,7 @@ from .const import ( CONF_RETAIN, CONF_STATE_TOPIC, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -160,32 +156,41 @@ class MqttTextEntity(MqttEntity, TextEntity): self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None self._attr_assumed_state = bool(self._optimistic) + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if check_state_too_long(_LOGGER, payload, self.entity_id, msg): + return + self._attr_native_value = payload + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} def add_subscription( - topics: dict[str, Any], topic: str, msg_callback: MessageCallbackType + topics: dict[str, Any], + topic: str, + msg_callback: MessageCallbackType, + tracked_attributes: set[str], ) -> None: if self._config.get(topic) is not None: topics[topic] = { "topic": self._config[topic], - "msg_callback": msg_callback, + "msg_callback": partial( + self._message_callback, msg_callback, tracked_attributes + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_native_value"}) - def handle_state_message_received(msg: ReceiveMessage) -> None: - """Handle receiving state message via MQTT.""" - payload = str(self._value_template(msg.payload)) - if check_state_too_long(_LOGGER, payload, self.entity_id, msg): - return - self._attr_native_value = payload - - add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) + add_subscription( + topics, + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics From fc9f7aee7e4369f3ad87334b76c26780a08c00f3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:17:54 +0200 Subject: [PATCH 157/164] Refactor mqtt callbacks for switch (#118127) --- homeassistant/components/mqtt/switch.py | 64 ++++++++++++------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 5cbfefe0111..8289b11adca 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial from typing import Any import voluptuous as vol @@ -36,12 +37,7 @@ from .const import ( CONF_STATE_TOPIC, PAYLOAD_NONE, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -118,38 +114,40 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): self._config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload = self._value_template(msg.payload) + if payload == self._state_on: + self._attr_is_on = True + elif payload == self._state_off: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT state messages.""" - payload = self._value_template(msg.payload) - if payload == self._state_on: - self._attr_is_on = True - elif payload == self._state_off: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_STATE_TOPIC: { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": state_message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + { + CONF_STATE_TOPIC: { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": partial( + self._message_callback, + self._state_message_received, + {"_attr_is_on"}, + ), + "entity_id": self.entity_id, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + }, + ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" From ae0c00218a4b9ebe0dc09e0edc7bbd12179fb860 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:19:37 +0200 Subject: [PATCH 158/164] Refactor mqtt callbacks for vacuum (#118137) --- homeassistant/components/mqtt/vacuum.py | 45 ++++++++++++------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 57265008025..b41242b4855 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging from typing import Any, cast @@ -49,12 +50,7 @@ from .const import ( CONF_STATE_TOPIC, DOMAIN, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic @@ -322,31 +318,32 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0) self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0))) + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle state MQTT message.""" + payload = json_loads_object(msg.payload) + if STATE in payload and ( + (state := payload[STATE]) in POSSIBLE_STATES or state is None + ): + self._attr_state = ( + POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None + ) + del payload[STATE] + self._update_state_attributes(payload) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_battery_level", "_attr_fan_speed", "_attr_state"} - ) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle state MQTT message.""" - payload = json_loads_object(msg.payload) - if STATE in payload and ( - (state := payload[STATE]) in POSSIBLE_STATES or state is None - ): - self._attr_state = ( - POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None - ) - del payload[STATE] - self._update_state_attributes(payload) - if state_topic := self._config.get(CONF_STATE_TOPIC): topics["state_position_topic"] = { "topic": state_topic, - "msg_callback": state_message_received, + "msg_callback": partial( + self._message_callback, + self._state_message_received, + {"_attr_battery_level", "_attr_fan_speed", "_attr_state"}, + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } From f21c0679b49f9f4780972b77921caf225449c9bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:23:45 +0200 Subject: [PATCH 159/164] Rework mqtt callbacks for camera, image and event (#118109) --- homeassistant/components/mqtt/camera.py | 30 +++-- homeassistant/components/mqtt/event.py | 159 ++++++++++++------------ homeassistant/components/mqtt/image.py | 90 +++++++------- homeassistant/components/mqtt/mixins.py | 12 +- 4 files changed, 148 insertions(+), 143 deletions(-) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 23457c8d4fc..f8ec099a295 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -3,6 +3,7 @@ from __future__ import annotations from base64 import b64decode +from functools import partial import logging from typing import TYPE_CHECKING @@ -20,7 +21,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_QOS, CONF_TOPIC -from .debug_info import log_messages from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -97,27 +97,31 @@ class MqttCamera(MqttEntity, Camera): """Return the config schema.""" return DISCOVERY_SCHEMA + @callback + def _image_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + if CONF_IMAGE_ENCODING in self._config: + self._last_image = b64decode(msg.payload) + else: + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) + self._last_image = msg.payload + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - if CONF_IMAGE_ENCODING in self._config: - self._last_image = b64decode(msg.payload) - else: - if TYPE_CHECKING: - assert isinstance(msg.payload, bytes) - self._last_image = msg.payload - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, { "state_topic": { "topic": self._config[CONF_TOPIC], - "msg_callback": message_received, + "msg_callback": partial( + self._message_callback, + self._image_received, + None, + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": None, } diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 5c8ae7f7be1..0fa82c7e12b 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging from typing import Any @@ -31,7 +32,6 @@ from .const import ( PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, ) -from .debug_info import log_messages from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, @@ -113,90 +113,91 @@ class MqttEvent(MqttEntity, EventEntity): self._config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _event_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + if msg.retain: + _LOGGER.debug( + "Ignoring event trigger from replayed retained payload '%s' on topic %s", + msg.payload, + msg.topic, + ) + return + event_attributes: dict[str, Any] = {} + event_type: str + try: + payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + except MqttValueTemplateException as exc: + _LOGGER.warning(exc) + return + if ( + not payload + or payload is PayloadSentinel.DEFAULT + or payload in (PAYLOAD_NONE, PAYLOAD_EMPTY_JSON) + ): + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + try: + event_attributes = json_loads_object(payload) + event_type = str(event_attributes.pop(event.ATTR_EVENT_TYPE)) + _LOGGER.debug( + ( + "JSON event data detected after processing payload '%s' on" + " topic %s, type %s, attributes %s" + ), + payload, + msg.topic, + event_type, + event_attributes, + ) + except KeyError: + _LOGGER.warning( + ("`event_type` missing in JSON event payload, " " '%s' on topic %s"), + payload, + msg.topic, + ) + return + except JSON_DECODE_EXCEPTIONS: + _LOGGER.warning( + ( + "No valid JSON event payload detected, " + "value after processing payload" + " '%s' on topic %s" + ), + payload, + msg.topic, + ) + return + try: + self._trigger_event(event_type, event_attributes) + except ValueError: + _LOGGER.warning( + "Invalid event type %s for %s received on topic %s, payload %s", + event_type, + self.entity_id, + msg.topic, + payload, + ) + return + mqtt_data = self.hass.data[DATA_MQTT] + mqtt_data.state_write_requests.write_state_request(self) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - if msg.retain: - _LOGGER.debug( - "Ignoring event trigger from replayed retained payload '%s' on topic %s", - msg.payload, - msg.topic, - ) - return - event_attributes: dict[str, Any] = {} - event_type: str - try: - payload = self._template(msg.payload, PayloadSentinel.DEFAULT) - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - if ( - not payload - or payload is PayloadSentinel.DEFAULT - or payload in (PAYLOAD_NONE, PAYLOAD_EMPTY_JSON) - ): - _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, - msg.topic, - ) - return - try: - event_attributes = json_loads_object(payload) - event_type = str(event_attributes.pop(event.ATTR_EVENT_TYPE)) - _LOGGER.debug( - ( - "JSON event data detected after processing payload '%s' on" - " topic %s, type %s, attributes %s" - ), - payload, - msg.topic, - event_type, - event_attributes, - ) - except KeyError: - _LOGGER.warning( - ( - "`event_type` missing in JSON event payload, " - " '%s' on topic %s" - ), - payload, - msg.topic, - ) - return - except JSON_DECODE_EXCEPTIONS: - _LOGGER.warning( - ( - "No valid JSON event payload detected, " - "value after processing payload" - " '%s' on topic %s" - ), - payload, - msg.topic, - ) - return - try: - self._trigger_event(event_type, event_attributes) - except ValueError: - _LOGGER.warning( - "Invalid event type %s for %s received on topic %s, payload %s", - event_type, - self.entity_id, - msg.topic, - payload, - ) - return - mqtt_data = self.hass.data[DATA_MQTT] - mqtt_data.state_write_requests.write_state_request(self) - topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, + "msg_callback": partial( + self._message_callback, + self._event_received, + None, + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index eec289aa464..3b7834a9876 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -5,6 +5,7 @@ from __future__ import annotations from base64 import b64decode import binascii from collections.abc import Callable +from functools import partial import logging from typing import TYPE_CHECKING, Any @@ -26,7 +27,6 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_ENCODING, CONF_QOS -from .debug_info import log_messages from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, @@ -143,6 +143,45 @@ class MqttImage(MqttEntity, ImageEntity): config.get(CONF_URL_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _image_data_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + try: + if CONF_IMAGE_ENCODING in self._config: + self._last_image = b64decode(msg.payload) + else: + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) + self._last_image = msg.payload + except (binascii.Error, ValueError, AssertionError) as err: + _LOGGER.error( + "Error processing image data received at topic %s: %s", + msg.topic, + err, + ) + self._last_image = None + self._attr_image_last_updated = dt_util.utcnow() + self.hass.data[DATA_MQTT].state_write_requests.write_state_request(self) + + @callback + def _image_from_url_request_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + try: + url = cv.url(self._url_template(msg.payload)) + self._attr_image_url = url + except MqttValueTemplateException as exc: + _LOGGER.warning(exc) + return + except vol.Invalid: + _LOGGER.error( + "Invalid image URL '%s' received at topic %s", + msg.payload, + msg.topic, + ) + self._attr_image_last_updated = dt_util.utcnow() + self._cached_image = None + self.hass.data[DATA_MQTT].state_write_requests.write_state_request(self) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -159,56 +198,15 @@ class MqttImage(MqttEntity, ImageEntity): if has_topic := self._topic[topic] is not None: topics[topic] = { "topic": self._topic[topic], - "msg_callback": msg_callback, + "msg_callback": partial(self._message_callback, msg_callback, None), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": encoding, } return has_topic - @callback - @log_messages(self.hass, self.entity_id) - def image_data_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - try: - if CONF_IMAGE_ENCODING in self._config: - self._last_image = b64decode(msg.payload) - else: - if TYPE_CHECKING: - assert isinstance(msg.payload, bytes) - self._last_image = msg.payload - except (binascii.Error, ValueError, AssertionError) as err: - _LOGGER.error( - "Error processing image data received at topic %s: %s", - msg.topic, - err, - ) - self._last_image = None - self._attr_image_last_updated = dt_util.utcnow() - self.hass.data[DATA_MQTT].state_write_requests.write_state_request(self) - - add_subscribe_topic(CONF_IMAGE_TOPIC, image_data_received) - - @callback - @log_messages(self.hass, self.entity_id) - def image_from_url_request_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - try: - url = cv.url(self._url_template(msg.payload)) - self._attr_image_url = url - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - except vol.Invalid: - _LOGGER.error( - "Invalid image URL '%s' received at topic %s", - msg.payload, - msg.topic, - ) - self._attr_image_last_updated = dt_util.utcnow() - self._cached_image = None - self.hass.data[DATA_MQTT].state_write_requests.write_state_request(self) - - add_subscribe_topic(CONF_URL_TOPIC, image_from_url_request_received) + add_subscribe_topic(CONF_IMAGE_TOPIC, self._image_data_received) + add_subscribe_topic(CONF_URL_TOPIC, self._image_from_url_request_received) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 8d294a45e97..f1fb0de6f4e 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -1254,13 +1254,15 @@ class MqttEntity( def _message_callback( self, msg_callback: MessageCallbackType, - attributes: set[str], + attributes: set[str] | None, msg: ReceiveMessage, ) -> None: """Process the message callback.""" - attrs_snapshot: tuple[tuple[str, Any | UndefinedType], ...] = tuple( - (attribute, getattr(self, attribute, UNDEFINED)) for attribute in attributes - ) + if attributes is not None: + attrs_snapshot: tuple[tuple[str, Any | UndefinedType], ...] = tuple( + (attribute, getattr(self, attribute, UNDEFINED)) + for attribute in attributes + ) mqtt_data = self.hass.data[DATA_MQTT] messages = mqtt_data.debug_info_entities[self.entity_id]["subscriptions"][ msg.subscribed_topic @@ -1274,7 +1276,7 @@ class MqttEntity( _LOGGER.warning(exc) return - if self._attrs_have_changed(attrs_snapshot): + if attributes is not None and self._attrs_have_changed(attrs_snapshot): mqtt_data.state_write_requests.write_state_request(self) From d4a95b3735f495e59f33774ddb29d052c77e0722 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:24:38 +0200 Subject: [PATCH 160/164] Refactor mqtt callbacks for light basic, json and template schema (#118113) --- .../components/mqtt/light/schema_basic.py | 473 +++++++++--------- .../components/mqtt/light/schema_json.py | 211 ++++---- .../components/mqtt/light/schema_template.py | 186 +++---- 3 files changed, 429 insertions(+), 441 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 904e45b3d2f..650ca1eff6a 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging from typing import Any, cast @@ -53,8 +54,7 @@ from ..const import ( CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -from ..debug_info import log_messages -from ..mixins import MqttEntity, write_state_on_attr_change +from ..mixins import MqttEntity from ..models import ( MessageCallbackType, MqttCommandTemplate, @@ -378,263 +378,248 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): attr: bool = getattr(self, f"_optimistic_{attribute}") return attr + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = self._value_templates[CONF_STATE_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.NONE + ) + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return + + if payload == self._payload["on"]: + self._attr_is_on = True + elif payload == self._payload["off"]: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None + + @callback + def _brightness_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for the brightness.""" + payload = self._value_templates[CONF_BRIGHTNESS_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty brightness message from '%s'", msg.topic) + return + + device_value = float(payload) + if device_value == 0: + _LOGGER.debug("Ignoring zero brightness from '%s'", msg.topic) + return + + percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] + self._attr_brightness = min(round(percent_bright * 255), 255) + + @callback + def _rgbx_received( + self, + msg: ReceiveMessage, + template: str, + color_mode: ColorMode, + convert_color: Callable[..., tuple[int, ...]], + ) -> tuple[int, ...] | None: + """Process MQTT messages for RGBW and RGBWW.""" + payload = self._value_templates[template](msg.payload, PayloadSentinel.DEFAULT) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty %s message from '%s'", color_mode, msg.topic) + return None + color = tuple(int(val) for val in str(payload).split(",")) + if self._optimistic_color_mode: + self._attr_color_mode = color_mode + if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: + rgb = convert_color(*color) + brightness = max(rgb) + if brightness == 0: + _LOGGER.debug( + "Ignoring %s message with zero rgb brightness from '%s'", + color_mode, + msg.topic, + ) + return None + self._attr_brightness = brightness + # Normalize the color to 100% brightness + color = tuple( + min(round(channel / brightness * 255), 255) for channel in color + ) + return color + + @callback + def _rgb_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for RGB.""" + rgb = self._rgbx_received( + msg, CONF_RGB_VALUE_TEMPLATE, ColorMode.RGB, lambda *x: x + ) + if rgb is None: + return + self._attr_rgb_color = cast(tuple[int, int, int], rgb) + + @callback + def _rgbw_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for RGBW.""" + rgbw = self._rgbx_received( + msg, + CONF_RGBW_VALUE_TEMPLATE, + ColorMode.RGBW, + color_util.color_rgbw_to_rgb, + ) + if rgbw is None: + return + self._attr_rgbw_color = cast(tuple[int, int, int, int], rgbw) + + @callback + def _rgbww_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for RGBWW.""" + + @callback + def _converter( + r: int, g: int, b: int, cw: int, ww: int + ) -> tuple[int, int, int]: + min_kelvin = color_util.color_temperature_mired_to_kelvin(self.max_mireds) + max_kelvin = color_util.color_temperature_mired_to_kelvin(self.min_mireds) + return color_util.color_rgbww_to_rgb( + r, g, b, cw, ww, min_kelvin, max_kelvin + ) + + rgbww = self._rgbx_received( + msg, + CONF_RGBWW_VALUE_TEMPLATE, + ColorMode.RGBWW, + _converter, + ) + if rgbww is None: + return + self._attr_rgbww_color = cast(tuple[int, int, int, int, int], rgbww) + + @callback + def _color_mode_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for color mode.""" + payload = self._value_templates[CONF_COLOR_MODE_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty color mode message from '%s'", msg.topic) + return + + self._attr_color_mode = ColorMode(str(payload)) + + @callback + def _color_temp_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for color temperature.""" + payload = self._value_templates[CONF_COLOR_TEMP_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty color temp message from '%s'", msg.topic) + return + + if self._optimistic_color_mode: + self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_color_temp = int(payload) + + @callback + def _effect_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for effect.""" + payload = self._value_templates[CONF_EFFECT_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty effect message from '%s'", msg.topic) + return + + self._attr_effect = str(payload) + + @callback + def _hs_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for hs color.""" + payload = self._value_templates[CONF_HS_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic) + return + try: + hs_color = tuple(float(val) for val in str(payload).split(",", 2)) + if self._optimistic_color_mode: + self._attr_color_mode = ColorMode.HS + self._attr_hs_color = cast(tuple[float, float], hs_color) + except ValueError: + _LOGGER.warning("Failed to parse hs state update: '%s'", payload) + + @callback + def _xy_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for xy color.""" + payload = self._value_templates[CONF_XY_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty xy-color message from '%s'", msg.topic) + return + + xy_color = tuple(float(val) for val in str(payload).split(",", 2)) + if self._optimistic_color_mode: + self._attr_color_mode = ColorMode.XY + self._attr_xy_color = cast(tuple[float, float], xy_color) + def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} - def add_topic(topic: str, msg_callback: MessageCallbackType) -> None: + def add_topic( + topic: str, msg_callback: MessageCallbackType, tracked_attributes: set[str] + ) -> None: """Add a topic.""" if self._topic[topic] is not None: topics[topic] = { "topic": self._topic[topic], - "msg_callback": msg_callback, + "msg_callback": partial( + self._message_callback, msg_callback, tracked_attributes + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload = self._value_templates[CONF_STATE_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.NONE - ) - if not payload: - _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) - return - - if payload == self._payload["on"]: - self._attr_is_on = True - elif payload == self._payload["off"]: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - - if self._topic[CONF_STATE_TOPIC] is not None: - topics[CONF_STATE_TOPIC] = { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_brightness"}) - def brightness_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for the brightness.""" - payload = self._value_templates[CONF_BRIGHTNESS_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty brightness message from '%s'", msg.topic) - return - - device_value = float(payload) - if device_value == 0: - _LOGGER.debug("Ignoring zero brightness from '%s'", msg.topic) - return - - percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] - self._attr_brightness = min(round(percent_bright * 255), 255) - - add_topic(CONF_BRIGHTNESS_STATE_TOPIC, brightness_received) - - @callback - def _rgbx_received( - msg: ReceiveMessage, - template: str, - color_mode: ColorMode, - convert_color: Callable[..., tuple[int, ...]], - ) -> tuple[int, ...] | None: - """Handle new MQTT messages for RGBW and RGBWW.""" - payload = self._value_templates[template]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug( - "Ignoring empty %s message from '%s'", color_mode, msg.topic - ) - return None - color = tuple(int(val) for val in str(payload).split(",")) - if self._optimistic_color_mode: - self._attr_color_mode = color_mode - if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: - rgb = convert_color(*color) - brightness = max(rgb) - if brightness == 0: - _LOGGER.debug( - "Ignoring %s message with zero rgb brightness from '%s'", - color_mode, - msg.topic, - ) - return None - self._attr_brightness = brightness - # Normalize the color to 100% brightness - color = tuple( - min(round(channel / brightness * 255), 255) for channel in color - ) - return color - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"} + add_topic(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) + add_topic( + CONF_BRIGHTNESS_STATE_TOPIC, self._brightness_received, {"_attr_brightness"} ) - def rgb_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for RGB.""" - rgb = _rgbx_received( - msg, CONF_RGB_VALUE_TEMPLATE, ColorMode.RGB, lambda *x: x - ) - if rgb is None: - return - self._attr_rgb_color = cast(tuple[int, int, int], rgb) - - add_topic(CONF_RGB_STATE_TOPIC, rgb_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"} + add_topic( + CONF_RGB_STATE_TOPIC, + self._rgb_received, + {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"}, ) - def rgbw_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for RGBW.""" - rgbw = _rgbx_received( - msg, - CONF_RGBW_VALUE_TEMPLATE, - ColorMode.RGBW, - color_util.color_rgbw_to_rgb, - ) - if rgbw is None: - return - self._attr_rgbw_color = cast(tuple[int, int, int, int], rgbw) - - add_topic(CONF_RGBW_STATE_TOPIC, rgbw_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"} + add_topic( + CONF_RGBW_STATE_TOPIC, + self._rgbw_received, + {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"}, + ) + add_topic( + CONF_RGBWW_STATE_TOPIC, + self._rgbww_received, + {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"}, + ) + add_topic( + CONF_COLOR_MODE_STATE_TOPIC, self._color_mode_received, {"_attr_color_mode"} + ) + add_topic( + CONF_COLOR_TEMP_STATE_TOPIC, + self._color_temp_received, + {"_attr_color_mode", "_attr_color_temp"}, + ) + add_topic(CONF_EFFECT_STATE_TOPIC, self._effect_received, {"_attr_effect"}) + add_topic( + CONF_HS_STATE_TOPIC, + self._hs_received, + {"_attr_color_mode", "_attr_hs_color"}, + ) + add_topic( + CONF_XY_STATE_TOPIC, + self._xy_received, + {"_attr_color_mode", "_attr_xy_color"}, ) - def rgbww_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for RGBWW.""" - - @callback - def _converter( - r: int, g: int, b: int, cw: int, ww: int - ) -> tuple[int, int, int]: - min_kelvin = color_util.color_temperature_mired_to_kelvin( - self.max_mireds - ) - max_kelvin = color_util.color_temperature_mired_to_kelvin( - self.min_mireds - ) - return color_util.color_rgbww_to_rgb( - r, g, b, cw, ww, min_kelvin, max_kelvin - ) - - rgbww = _rgbx_received( - msg, - CONF_RGBWW_VALUE_TEMPLATE, - ColorMode.RGBWW, - _converter, - ) - if rgbww is None: - return - self._attr_rgbww_color = cast(tuple[int, int, int, int, int], rgbww) - - add_topic(CONF_RGBWW_STATE_TOPIC, rgbww_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode"}) - def color_mode_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for color mode.""" - payload = self._value_templates[CONF_COLOR_MODE_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty color mode message from '%s'", msg.topic) - return - - self._attr_color_mode = ColorMode(str(payload)) - - add_topic(CONF_COLOR_MODE_STATE_TOPIC, color_mode_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_color_temp"}) - def color_temp_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for color temperature.""" - payload = self._value_templates[CONF_COLOR_TEMP_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty color temp message from '%s'", msg.topic) - return - - if self._optimistic_color_mode: - self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = int(payload) - - add_topic(CONF_COLOR_TEMP_STATE_TOPIC, color_temp_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_effect"}) - def effect_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for effect.""" - payload = self._value_templates[CONF_EFFECT_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty effect message from '%s'", msg.topic) - return - - self._attr_effect = str(payload) - - add_topic(CONF_EFFECT_STATE_TOPIC, effect_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_hs_color"}) - def hs_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for hs color.""" - payload = self._value_templates[CONF_HS_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic) - return - try: - hs_color = tuple(float(val) for val in str(payload).split(",", 2)) - if self._optimistic_color_mode: - self._attr_color_mode = ColorMode.HS - self._attr_hs_color = cast(tuple[float, float], hs_color) - except ValueError: - _LOGGER.warning("Failed to parse hs state update: '%s'", payload) - - add_topic(CONF_HS_STATE_TOPIC, hs_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_xy_color"}) - def xy_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for xy color.""" - payload = self._value_templates[CONF_XY_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty xy-color message from '%s'", msg.topic) - return - - xy_color = tuple(float(val) for val in str(payload).split(",", 2)) - if self._optimistic_color_mode: - self._attr_color_mode = ColorMode.XY - self._attr_xy_color = cast(tuple[float, float], xy_color) - - add_topic(CONF_XY_STATE_TOPIC, xy_received) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 52fbf3429b6..14e477d0c35 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress +from functools import partial import logging from typing import TYPE_CHECKING, Any, cast @@ -66,8 +67,7 @@ from ..const import ( CONF_STATE_TOPIC, DOMAIN as MQTT_DOMAIN, ) -from ..debug_info import log_messages -from ..mixins import MqttEntity, write_state_on_attr_change +from ..mixins import MqttEntity from ..models import ReceiveMessage from ..schemas import MQTT_ENTITY_COMMON_SCHEMA from ..util import valid_subscribe_topic @@ -414,114 +414,117 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self.entity_id, ) + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + values = json_loads_object(msg.payload) + + if values["state"] == "ON": + self._attr_is_on = True + elif values["state"] == "OFF": + self._attr_is_on = False + elif values["state"] is None: + self._attr_is_on = None + + if ( + self._deprecated_color_handling + and color_supported(self.supported_color_modes) + and "color" in values + ): + # Deprecated color handling + if values["color"] is None: + self._attr_hs_color = None + else: + self._update_color(values) + + if not self._deprecated_color_handling and "color_mode" in values: + self._update_color(values) + + if brightness_supported(self.supported_color_modes): + try: + if brightness := values["brightness"]: + if TYPE_CHECKING: + assert isinstance(brightness, float) + self._attr_brightness = color_util.value_to_brightness( + (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness + ) + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + + except KeyError: + pass + except (TypeError, ValueError): + _LOGGER.warning( + "Invalid brightness value '%s' received for entity %s", + values["brightness"], + self.entity_id, + ) + + if ( + self._deprecated_color_handling + and self.supported_color_modes + and ColorMode.COLOR_TEMP in self.supported_color_modes + ): + # Deprecated color handling + try: + if values["color_temp"] is None: + self._attr_color_temp = None + else: + self._attr_color_temp = int(values["color_temp"]) # type: ignore[arg-type] + except KeyError: + pass + except ValueError: + _LOGGER.warning( + "Invalid color temp value '%s' received for entity %s", + values["color_temp"], + self.entity_id, + ) + # Allow to switch back to color_temp + if "color" not in values: + self._attr_hs_color = None + + if self.supported_features and LightEntityFeature.EFFECT: + with suppress(KeyError): + self._attr_effect = cast(str, values["effect"]) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + # + if self._topic[CONF_STATE_TOPIC] is None: + return + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, { - "_attr_brightness", - "_attr_color_temp", - "_attr_effect", - "_attr_hs_color", - "_attr_is_on", - "_attr_rgb_color", - "_attr_rgbw_color", - "_attr_rgbww_color", - "_attr_xy_color", - "color_mode", + CONF_STATE_TOPIC: { + "topic": self._topic[CONF_STATE_TOPIC], + "msg_callback": partial( + self._message_callback, + self._state_received, + { + "_attr_brightness", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", + "_attr_rgb_color", + "_attr_rgbw_color", + "_attr_rgbww_color", + "_attr_xy_color", + "color_mode", + }, + ), + "entity_id": self.entity_id, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } }, ) - def state_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - values = json_loads_object(msg.payload) - - if values["state"] == "ON": - self._attr_is_on = True - elif values["state"] == "OFF": - self._attr_is_on = False - elif values["state"] is None: - self._attr_is_on = None - - if ( - self._deprecated_color_handling - and color_supported(self.supported_color_modes) - and "color" in values - ): - # Deprecated color handling - if values["color"] is None: - self._attr_hs_color = None - else: - self._update_color(values) - - if not self._deprecated_color_handling and "color_mode" in values: - self._update_color(values) - - if brightness_supported(self.supported_color_modes): - try: - if brightness := values["brightness"]: - if TYPE_CHECKING: - assert isinstance(brightness, float) - self._attr_brightness = color_util.value_to_brightness( - (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness - ) - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, - ) - - except KeyError: - pass - except (TypeError, ValueError): - _LOGGER.warning( - "Invalid brightness value '%s' received for entity %s", - values["brightness"], - self.entity_id, - ) - - if ( - self._deprecated_color_handling - and self.supported_color_modes - and ColorMode.COLOR_TEMP in self.supported_color_modes - ): - # Deprecated color handling - try: - if values["color_temp"] is None: - self._attr_color_temp = None - else: - self._attr_color_temp = int(values["color_temp"]) # type: ignore[arg-type] - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid color temp value '%s' received for entity %s", - values["color_temp"], - self.entity_id, - ) - # Allow to switch back to color_temp - if "color" not in values: - self._attr_hs_color = None - - if self.supported_features and LightEntityFeature.EFFECT: - with suppress(KeyError): - self._attr_effect = cast(str, values["effect"]) - - if self._topic[CONF_STATE_TOPIC] is not None: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 651b691e28e..647bf6df401 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from functools import partial import logging from typing import Any @@ -44,8 +45,7 @@ from ..const import ( CONF_STATE_TOPIC, PAYLOAD_NONE, ) -from ..debug_info import log_messages -from ..mixins import MqttEntity, write_state_on_attr_change +from ..mixins import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, @@ -188,103 +188,103 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): # Support for ct + hs, prioritize hs self._attr_color_mode = ColorMode.HS if self.hs_color else ColorMode.COLOR_TEMP + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) + if state == STATE_ON: + self._attr_is_on = True + elif state == STATE_OFF: + self._attr_is_on = False + elif state == PAYLOAD_NONE: + self._attr_is_on = None + else: + _LOGGER.warning("Invalid state value received") + + if CONF_BRIGHTNESS_TEMPLATE in self._config: + try: + if brightness := int( + self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) + ): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + + except ValueError: + _LOGGER.warning("Invalid brightness value received from %s", msg.topic) + + if CONF_COLOR_TEMP_TEMPLATE in self._config: + try: + color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( + msg.payload + ) + self._attr_color_temp = ( + int(color_temp) if color_temp != "None" else None + ) + except ValueError: + _LOGGER.warning("Invalid color temperature value received") + + if ( + CONF_RED_TEMPLATE in self._config + and CONF_GREEN_TEMPLATE in self._config + and CONF_BLUE_TEMPLATE in self._config + ): + try: + red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) + green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) + blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) + if red == "None" and green == "None" and blue == "None": + self._attr_hs_color = None + else: + self._attr_hs_color = color_util.color_RGB_to_hs( + int(red), int(green), int(blue) + ) + self._update_color_mode() + except ValueError: + _LOGGER.warning("Invalid color value received") + + if CONF_EFFECT_TEMPLATE in self._config: + effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) + if ( + effect_list := self._config[CONF_EFFECT_LIST] + ) and effect in effect_list: + self._attr_effect = effect + else: + _LOGGER.warning("Unsupported effect value received") + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + if self._topics[CONF_STATE_TOPIC] is None: + return + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, { - "_attr_brightness", - "_attr_color_mode", - "_attr_color_temp", - "_attr_effect", - "_attr_hs_color", - "_attr_is_on", + "state_topic": { + "topic": self._topics[CONF_STATE_TOPIC], + "msg_callback": partial( + self._message_callback, + self._state_received, + { + "_attr_brightness", + "_attr_color_mode", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", + }, + ), + "entity_id": self.entity_id, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } }, ) - def state_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) - if state == STATE_ON: - self._attr_is_on = True - elif state == STATE_OFF: - self._attr_is_on = False - elif state == PAYLOAD_NONE: - self._attr_is_on = None - else: - _LOGGER.warning("Invalid state value received") - - if CONF_BRIGHTNESS_TEMPLATE in self._config: - try: - if brightness := int( - self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ): - self._attr_brightness = brightness - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, - ) - - except ValueError: - _LOGGER.warning( - "Invalid brightness value received from %s", msg.topic - ) - - if CONF_COLOR_TEMP_TEMPLATE in self._config: - try: - color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( - msg.payload - ) - self._attr_color_temp = ( - int(color_temp) if color_temp != "None" else None - ) - except ValueError: - _LOGGER.warning("Invalid color temperature value received") - - if ( - CONF_RED_TEMPLATE in self._config - and CONF_GREEN_TEMPLATE in self._config - and CONF_BLUE_TEMPLATE in self._config - ): - try: - red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) - green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) - blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) - if red == "None" and green == "None" and blue == "None": - self._attr_hs_color = None - else: - self._attr_hs_color = color_util.color_RGB_to_hs( - int(red), int(green), int(blue) - ) - self._update_color_mode() - except ValueError: - _LOGGER.warning("Invalid color value received") - - if CONF_EFFECT_TEMPLATE in self._config: - effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) - if ( - effect_list := self._config[CONF_EFFECT_LIST] - ) and effect in effect_list: - self._attr_effect = effect - else: - _LOGGER.warning("Unsupported effect value received") - - if self._topics[CONF_STATE_TOPIC] is not None: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._topics[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" From ecd48cc447c696be0ca470aa8acadfc3dde74e89 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 26 May 2024 00:28:48 +0300 Subject: [PATCH 161/164] Clean up Shelly unneccesary async_block_till_done calls (#118141) --- tests/components/shelly/test_climate.py | 1 - tests/components/shelly/test_number.py | 1 - tests/components/shelly/test_sensor.py | 2 -- tests/components/shelly/test_switch.py | 2 -- tests/components/shelly/test_update.py | 2 -- 5 files changed, 8 deletions(-) diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 241c6a00724..aac14c24288 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -492,7 +492,6 @@ async def test_block_set_mode_auth_error( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 0b9fee9e47f..a5f64409d09 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -227,7 +227,6 @@ async def test_block_set_value_auth_error( {ATTR_ENTITY_ID: "number.test_name_valve_position", ATTR_VALUE: 30}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index ceaa9b66b8d..e7bac38c7fd 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -618,7 +618,6 @@ async def test_rpc_sleeping_update_entity_service( service_data={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() # Entity should be available after update_entity service call state = hass.states.get(entity_id) @@ -667,7 +666,6 @@ async def test_block_sleeping_update_entity_service( service_data={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() # Entity should be available after update_entity service call state = hass.states.get(entity_id) diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 3bcb262bee1..212fd4e6bab 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -230,7 +230,6 @@ async def test_block_set_state_auth_error( {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -374,7 +373,6 @@ async def test_rpc_auth_error( {ATTR_ENTITY_ID: "switch.test_switch_0"}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 0f26fd14d12..b4ec42762bb 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -207,7 +207,6 @@ async def test_block_update_auth_error( {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -669,7 +668,6 @@ async def test_rpc_update_auth_error( blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() From 9be829ba1f5e7e1c2f080079efb6ee6322f84291 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 May 2024 11:34:24 -1000 Subject: [PATCH 162/164] Make mqtt internal subscription a normal function (#118092) Co-authored-by: Jan Bouwhuis --- homeassistant/components/mqtt/__init__.py | 5 +- .../components/mqtt/alarm_control_panel.py | 2 +- .../components/mqtt/binary_sensor.py | 2 +- homeassistant/components/mqtt/camera.py | 2 +- homeassistant/components/mqtt/client.py | 71 +++++++++++-------- homeassistant/components/mqtt/climate.py | 2 +- homeassistant/components/mqtt/cover.py | 2 +- .../components/mqtt/device_tracker.py | 2 +- homeassistant/components/mqtt/event.py | 2 +- homeassistant/components/mqtt/fan.py | 2 +- homeassistant/components/mqtt/humidifier.py | 2 +- homeassistant/components/mqtt/image.py | 2 +- homeassistant/components/mqtt/lawn_mower.py | 2 +- .../components/mqtt/light/schema_basic.py | 2 +- .../components/mqtt/light/schema_json.py | 2 +- .../components/mqtt/light/schema_template.py | 2 +- homeassistant/components/mqtt/lock.py | 2 +- homeassistant/components/mqtt/mixins.py | 20 +++--- homeassistant/components/mqtt/number.py | 2 +- homeassistant/components/mqtt/select.py | 2 +- homeassistant/components/mqtt/sensor.py | 2 +- homeassistant/components/mqtt/siren.py | 2 +- homeassistant/components/mqtt/subscription.py | 54 +++++++++----- homeassistant/components/mqtt/switch.py | 2 +- homeassistant/components/mqtt/tag.py | 2 +- homeassistant/components/mqtt/text.py | 2 +- homeassistant/components/mqtt/update.py | 2 +- homeassistant/components/mqtt/vacuum.py | 2 +- homeassistant/components/mqtt/valve.py | 2 +- tests/components/mqtt/test_init.py | 23 +++++- 30 files changed, 140 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3391312bdd0..39e2660ca03 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -39,6 +39,7 @@ from .client import ( # noqa: F401 MQTT, async_publish, async_subscribe, + async_subscribe_internal, publish, subscribe, ) @@ -311,7 +312,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def collect_msg(msg: ReceiveMessage) -> None: messages.append((msg.topic, str(msg.payload).replace("\n", ""))) - unsub = await async_subscribe(hass, call.data["topic"], collect_msg) + unsub = async_subscribe_internal(hass, call.data["topic"], collect_msg) def write_dump() -> None: with open(hass.config.path("mqtt_dump.txt"), "w", encoding="utf8") as fp: @@ -459,7 +460,7 @@ async def websocket_subscribe( # Perform UTF-8 decoding directly in callback routine qos: int = msg.get("qos", DEFAULT_QOS) - connection.subscriptions[msg["id"]] = await async_subscribe( + connection.subscriptions[msg["id"]] = async_subscribe_internal( hass, msg["topic"], forward_messages, encoding=None, qos=qos ) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index e341d54e349..fe6650cbd0f 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -226,7 +226,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index ce772855e78..61e5074378d 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -254,7 +254,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @callback def _value_is_expired(self, *_: Any) -> None: diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index f8ec099a295..2c6346f5794 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -130,7 +130,7 @@ class MqttCamera(MqttEntity, Camera): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 0e9f7f06e21..16db9a45b58 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -191,13 +191,25 @@ async def async_subscribe( Call the return value to unsubscribe. """ - if not mqtt_config_entry_enabled(hass): - raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', MQTT is not enabled", - translation_key="mqtt_not_setup_cannot_subscribe", - translation_domain=DOMAIN, - translation_placeholders={"topic": topic}, - ) + return async_subscribe_internal(hass, topic, msg_callback, qos, encoding) + + +@callback +def async_subscribe_internal( + hass: HomeAssistant, + topic: str, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], + qos: int = DEFAULT_QOS, + encoding: str | None = DEFAULT_ENCODING, +) -> CALLBACK_TYPE: + """Subscribe to an MQTT topic. + + This function is internal to the MQTT integration + and may change at any time. It should not be considered + a stable API. + + Call the return value to unsubscribe. + """ try: mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: @@ -208,12 +220,15 @@ async def async_subscribe( translation_domain=DOMAIN, translation_placeholders={"topic": topic}, ) from exc - return await mqtt_data.client.async_subscribe( - topic, - msg_callback, - qos, - encoding, - ) + client = mqtt_data.client + if not client.connected and not mqtt_config_entry_enabled(hass): + raise HomeAssistantError( + f"Cannot subscribe to topic '{topic}', MQTT is not enabled", + translation_key="mqtt_not_setup_cannot_subscribe", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, + ) + return client.async_subscribe(topic, msg_callback, qos, encoding) @bind_hass @@ -845,17 +860,15 @@ class MQTT: f"'{msg.topic}': '{msg.payload}'" # type: ignore[str-bytes-safe] ) - async def async_subscribe( + @callback + def async_subscribe( self, topic: str, msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], qos: int, encoding: str | None = None, ) -> Callable[[], None]: - """Set up a subscription to a topic with the provided qos. - - This method is a coroutine. - """ + """Set up a subscription to a topic with the provided qos.""" if not isinstance(topic, str): raise HomeAssistantError("Topic needs to be a string!") @@ -881,18 +894,18 @@ class MQTT: if self.connected: self._async_queue_subscriptions(((topic, qos),)) - @callback - def async_remove() -> None: - """Remove subscription.""" - self._async_untrack_subscription(subscription) - self._matching_subscriptions.cache_clear() - if subscription in self._retained_topics: - del self._retained_topics[subscription] - # Only unsubscribe if currently connected - if self.connected: - self._async_unsubscribe(topic) + return partial(self._async_remove, subscription) - return async_remove + @callback + def _async_remove(self, subscription: Subscription) -> None: + """Remove subscription.""" + self._async_untrack_subscription(subscription) + self._matching_subscriptions.cache_clear() + if subscription in self._retained_topics: + del self._retained_topics[subscription] + # Only unsubscribe if currently connected + if self.connected: + self._async_unsubscribe(subscription.topic) @callback def _async_unsubscribe(self, topic: str) -> None: diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index b09ee17af68..57f71008ecc 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -511,7 +511,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def _publish(self, topic: str, payload: PublishPayloadType) -> None: if self._topic[topic] is not None: diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index d741f602670..a4c7c1d8b3b 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -512,7 +512,7 @@ class MqttCover(MqttEntity, CoverEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 9af85d5ab9f..87abba2ac95 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -166,7 +166,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @property def latitude(self) -> float | None: diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 0fa82c7e12b..a09579fccef 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -208,4 +208,4 @@ class MqttEvent(MqttEntity, EventEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 1ee7bc63796..a418131d5c5 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -477,7 +477,7 @@ class MqttFan(MqttEntity, FanEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 7956a05d20a..097018f008f 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -447,7 +447,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the entity. diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 3b7834a9876..4fa410c4595 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -214,7 +214,7 @@ class MqttImage(MqttEntity, ImageEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_image(self) -> bytes | None: """Return bytes of image.""" diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 3ce04ca29d5..2452b511144 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -198,7 +198,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._attr_assumed_state and ( last_state := await self.async_get_last_state() diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 650ca1eff6a..583374c8d20 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -627,7 +627,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) last_state = await self.async_get_last_state() def restore_state( diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 14e477d0c35..f6dec17f8f3 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -528,7 +528,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) last_state = await self.async_get_last_state() if self._optimistic and last_state: diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 647bf6df401..193b4d23931 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -288,7 +288,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) last_state = await self.async_get_last_state() if self._optimistic and last_state: diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 33d25b168a8..52c2bea2cc3 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -243,7 +243,7 @@ class MqttLock(MqttEntity, LockEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_lock(self, **kwargs: Any) -> None: """Lock the device. diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index f1fb0de6f4e..0331b49c2a6 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -114,7 +114,7 @@ from .models import ( from .subscription import ( EntitySubscription, async_prepare_subscribe_topics, - async_subscribe_topics, + async_subscribe_topics_internal, async_unsubscribe_topics, ) from .util import mqtt_config_entry_enabled @@ -413,7 +413,7 @@ class MqttAttributesMixin(Entity): """Subscribe MQTT events.""" await super().async_added_to_hass() self._attributes_prepare_subscribe_topics() - await self._attributes_subscribe_topics() + self._attributes_subscribe_topics() def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" @@ -422,7 +422,7 @@ class MqttAttributesMixin(Entity): async def attributes_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" - await self._attributes_subscribe_topics() + self._attributes_subscribe_topics() def _attributes_prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -447,9 +447,10 @@ class MqttAttributesMixin(Entity): }, ) - async def _attributes_subscribe_topics(self) -> None: + @callback + def _attributes_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await async_subscribe_topics(self.hass, self._attributes_sub_state) + async_subscribe_topics_internal(self.hass, self._attributes_sub_state) async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" @@ -494,7 +495,7 @@ class MqttAvailabilityMixin(Entity): """Subscribe MQTT events.""" await super().async_added_to_hass() self._availability_prepare_subscribe_topics() - await self._availability_subscribe_topics() + self._availability_subscribe_topics() self.async_on_remove( async_dispatcher_connect(self.hass, MQTT_CONNECTED, self.async_mqtt_connect) ) @@ -511,7 +512,7 @@ class MqttAvailabilityMixin(Entity): async def availability_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" - await self._availability_subscribe_topics() + self._availability_subscribe_topics() def _availability_setup_from_config(self, config: ConfigType) -> None: """(Re)Setup.""" @@ -579,9 +580,10 @@ class MqttAvailabilityMixin(Entity): self._available[topic] = False self._available_latest = False - async def _availability_subscribe_topics(self) -> None: + @callback + def _availability_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await async_subscribe_topics(self.hass, self._availability_sub_state) + async_subscribe_topics_internal(self.hass, self._availability_sub_state) @callback def async_mqtt_connect(self) -> None: diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index f381087bd37..17e7cfe69e0 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -220,7 +220,7 @@ class MqttNumber(MqttEntity, RestoreNumber): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._attr_assumed_state and ( last_number_data := await self.async_get_last_number_data() diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index f37a2b1e231..a2814055a7c 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -160,7 +160,7 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._attr_assumed_state and ( last_state := await self.async_get_last_state() diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index d37da597ffb..c8fe932ed71 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -305,7 +305,7 @@ class MqttSensor(MqttEntity, RestoreSensor): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @callback def _value_is_expired(self, *_: datetime) -> None: diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 5920efbc3c1..06cb2677c09 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -288,7 +288,7 @@ class MqttSiren(MqttEntity, SirenEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index d0dc98484b3..9e3ea21222f 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -2,14 +2,15 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Any -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback -from .. import mqtt from . import debug_info +from .client import async_subscribe_internal from .const import DEFAULT_QOS from .models import MessageCallbackType @@ -21,7 +22,7 @@ class EntitySubscription: hass: HomeAssistant topic: str | None message_callback: MessageCallbackType - subscribe_task: Coroutine[Any, Any, Callable[[], None]] | None + should_subscribe: bool | None unsubscribe_callback: Callable[[], None] | None qos: int = 0 encoding: str = "utf-8" @@ -53,15 +54,16 @@ class EntitySubscription: self.hass, self.message_callback, self.topic, self.entity_id ) - self.subscribe_task = mqtt.async_subscribe( - hass, self.topic, self.message_callback, self.qos, self.encoding - ) + self.should_subscribe = True - async def subscribe(self) -> None: + @callback + def subscribe(self) -> None: """Subscribe to a topic.""" - if not self.subscribe_task: + if not self.should_subscribe or not self.topic: return - self.unsubscribe_callback = await self.subscribe_task + self.unsubscribe_callback = async_subscribe_internal( + self.hass, self.topic, self.message_callback, self.qos, self.encoding + ) def _should_resubscribe(self, other: EntitySubscription | None) -> bool: """Check if we should re-subscribe to the topic using the old state.""" @@ -79,6 +81,7 @@ class EntitySubscription: ) +@callback def async_prepare_subscribe_topics( hass: HomeAssistant, new_state: dict[str, EntitySubscription] | None, @@ -107,7 +110,7 @@ def async_prepare_subscribe_topics( qos=value.get("qos", DEFAULT_QOS), encoding=value.get("encoding", "utf-8"), hass=hass, - subscribe_task=None, + should_subscribe=None, entity_id=value.get("entity_id", None), ) # Get the current subscription state @@ -135,12 +138,29 @@ async def async_subscribe_topics( sub_state: dict[str, EntitySubscription], ) -> None: """(Re)Subscribe to a set of MQTT topics.""" + async_subscribe_topics_internal(hass, sub_state) + + +@callback +def async_subscribe_topics_internal( + hass: HomeAssistant, + sub_state: dict[str, EntitySubscription], +) -> None: + """(Re)Subscribe to a set of MQTT topics. + + This function is internal to the MQTT integration and should not be called + from outside the integration. + """ for sub in sub_state.values(): - await sub.subscribe() + sub.subscribe() -def async_unsubscribe_topics( - hass: HomeAssistant, sub_state: dict[str, EntitySubscription] | None -) -> dict[str, EntitySubscription]: - """Unsubscribe from all MQTT topics managed by async_subscribe_topics.""" - return async_prepare_subscribe_topics(hass, sub_state, {}) +if TYPE_CHECKING: + + def async_unsubscribe_topics( + hass: HomeAssistant, sub_state: dict[str, EntitySubscription] | None + ) -> dict[str, EntitySubscription]: + """Unsubscribe from all MQTT topics managed by async_subscribe_topics.""" + + +async_unsubscribe_topics = partial(async_prepare_subscribe_topics, topics={}) diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 8289b11adca..9f266a0e9ab 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -151,7 +151,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._optimistic and (last_state := await self.async_get_last_state()): self._attr_is_on = last_state.state == STATE_ON diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 4ecf0862827..55f7e775ae9 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -167,7 +167,7 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdateMixin): } }, ) - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_tear_down(self) -> None: """Cleanup tag scanner.""" diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index c563195e6e0..abced8b8744 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -198,7 +198,7 @@ class MqttTextEntity(MqttEntity, TextEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_set_value(self, value: str) -> None: """Change the text.""" diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 9b6ee901eaf..ee29601e585 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -257,7 +257,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index b41242b4855..5c8c2fd2ba5 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -353,7 +353,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: """Publish a command.""" diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 89a60eef852..2536d9beb40 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -371,7 +371,7 @@ class MqttValve(MqttEntity, ValveEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_open_valve(self) -> None: """Move the valve up. diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 57056819784..9421cddc6a2 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1051,6 +1051,27 @@ async def test_subscribe_topic_not_initialize( await mqtt.async_subscribe(hass, "test-topic", record_calls) +async def test_subscribe_mqtt_config_entry_disabled( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test the subscription of a topic when MQTT config entry is disabled.""" + mqtt_mock.connected = True + + mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + assert mqtt_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) + assert mqtt_config_entry.state is ConfigEntryState.NOT_LOADED + + await hass.config_entries.async_set_disabled_by( + mqtt_config_entry.entry_id, ConfigEntryDisabler.USER + ) + mqtt_mock.connected = False + + with pytest.raises(HomeAssistantError, match=r".*MQTT is not enabled"): + await mqtt.async_subscribe(hass, "test-topic", record_calls) + + @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.2) async def test_subscribe_and_resubscribe( @@ -3824,7 +3845,7 @@ async def test_unload_config_entry( async def test_publish_or_subscribe_without_valid_config_entry( hass: HomeAssistant, record_calls: MessageCallbackType ) -> None: - """Test internal publish function with bas use cases.""" + """Test internal publish function with bad use cases.""" with pytest.raises(HomeAssistantError): await mqtt.async_publish( hass, "some-topic", "test-payload", qos=0, retain=False, encoding=None From 991d6d92dbc0d31bf25fb23b7b9d81354ca329b4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 25 May 2024 23:34:56 +0200 Subject: [PATCH 163/164] Refactor mqtt callbacks for valve (#118140) --- homeassistant/components/mqtt/valve.py | 110 ++++++++++++------------- 1 file changed, 52 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 2536d9beb40..ce89c6c2daf 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import suppress +from functools import partial import logging from typing import Any @@ -61,12 +62,7 @@ from .const import ( DEFAULT_RETAIN, PAYLOAD_NONE, ) -from .debug_info import log_messages -from .mixins import ( - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic @@ -302,65 +298,63 @@ class MqttValve(MqttEntity, ValveEntity): return self._update_state(state) + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload = self._value_template(msg.payload) + payload_dict: Any = None + position_payload: Any = payload + state_payload: Any = payload + + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return + + with suppress(*JSON_DECODE_EXCEPTIONS): + payload_dict = json_loads(payload) + if isinstance(payload_dict, dict): + if self.reports_position and "position" not in payload_dict: + _LOGGER.warning( + "Missing required `position` attribute in json payload " + "on topic '%s', got: %s", + msg.topic, + payload, + ) + return + if not self.reports_position and "state" not in payload_dict: + _LOGGER.warning( + "Missing required `state` attribute in json payload " + " on topic '%s', got: %s", + msg.topic, + payload, + ) + return + position_payload = payload_dict.get("position") + state_payload = payload_dict.get("state") + + if self._config[CONF_REPORTS_POSITION]: + self._process_position_valve_update(msg, position_payload, state_payload) + else: + self._process_binary_valve_update(msg, state_payload) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics = {} - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, - { - "_attr_current_valve_position", - "_attr_is_closed", - "_attr_is_closing", - "_attr_is_opening", - }, - ) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT state messages.""" - payload = self._value_template(msg.payload) - payload_dict: Any = None - position_payload: Any = payload - state_payload: Any = payload - - if not payload: - _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) - return - - with suppress(*JSON_DECODE_EXCEPTIONS): - payload_dict = json_loads(payload) - if isinstance(payload_dict, dict): - if self.reports_position and "position" not in payload_dict: - _LOGGER.warning( - "Missing required `position` attribute in json payload " - "on topic '%s', got: %s", - msg.topic, - payload, - ) - return - if not self.reports_position and "state" not in payload_dict: - _LOGGER.warning( - "Missing required `state` attribute in json payload " - " on topic '%s', got: %s", - msg.topic, - payload, - ) - return - position_payload = payload_dict.get("position") - state_payload = payload_dict.get("state") - - if self._config[CONF_REPORTS_POSITION]: - self._process_position_valve_update( - msg, position_payload, state_payload - ) - else: - self._process_binary_valve_update(msg, state_payload) - if self._config.get(CONF_STATE_TOPIC): topics["state_topic"] = { "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": state_message_received, + "msg_callback": partial( + self._message_callback, + self._state_message_received, + { + "_attr_current_valve_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ), + "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } From 6a0e7cfea54543326e518def401f2e88b212bad8 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 26 May 2024 00:37:44 +0300 Subject: [PATCH 164/164] Clean up WebOS TV unneccesary async_block_till_done calls (#118142) --- tests/components/webostv/test_device_trigger.py | 1 - tests/components/webostv/test_trigger.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 5205f6ae7a1..8d62d4e0b17 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -92,7 +92,6 @@ async def test_if_fires_on_turn_on_request(hass: HomeAssistant, calls, client) - blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 2 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index dd119bd0d5a..73c55df8807 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -56,7 +56,6 @@ async def test_webostv_turn_on_trigger_device_id( blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 @@ -74,7 +73,6 @@ async def test_webostv_turn_on_trigger_device_id( blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 0 @@ -113,7 +111,6 @@ async def test_webostv_turn_on_trigger_entity_id( blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == ENTITY_ID assert calls[0].data["id"] == 0